codezero/laravel-unique-translation
Laravel validation rule for ensuring translated attribute values are unique per locale. Works with translatable models and JSON columns to prevent duplicate translations in forms, imports, and admin panels.
The package adds a single validation rule object that checks a value inside a JSON column for uniqueness, scoped to a specific locale key. It is not a database constraint, not a trait, and has no config file — install and use.
composer require codezero/laravel-unique-translation
Laravel package discovery registers the service provider automatically. There is nothing to publish.
First use case: an articles table with a JSON title column holding {"en": "...", "fr": "..."}, and a form that submits title[en] / title[fr]. Laravel's built-in unique rule cannot see inside JSON, and whereJsonContains is awkward to wire into a validator by hand — this package is exactly that wiring.
// migration
$table->json('title')->nullable();
// model
protected $casts = ['title' => 'array'];
// app/Http/Requests/StoreArticleRequest.php
use CodeZero\UniqueTranslation\UniqueTranslationRule;
public function rules(): array
{
return [
'title.en' => [
'required', 'string', 'max:255',
UniqueTranslationRule::for('articles', 'title'),
],
'title.fr' => [
'nullable', 'string', 'max:255',
UniqueTranslationRule::for('articles', 'title'),
],
];
}
Where to look first: the README's three examples (for(), ignore(), explicit column), then the rule class itself in src/. It is small enough to read end-to-end in a few minutes, which matters because the rule is a thin wrapper over a query builder call — knowing precisely what query it builds answers most questions you will have later.
Derive the column from the attribute. The second argument to for() is optional; the rule strips the trailing locale segment from the attribute name and uses the remainder as the column. So title.en with UniqueTranslationRule::for('articles') queries articles.title->en. Pass the column explicitly whenever the input name and column name diverge (e.g. form field name[en] mapped to a slug column).
Ignore the current record on update. Chain ignore() with an ID or an Eloquent model:
public function rules(): array
{
return [
'title.en' => [
'required', 'string', 'max:255',
UniqueTranslationRule::for('articles', 'title')
->ignore($this->route('article')),
],
];
}
Pass the model rather than the ID when the route model binding is available — it lets the rule resolve the primary key and, depending on version, the model's table.
Build rules per locale in a loop. For sites with many locales, generate instead of repeating:
public function rules(): array
{
$rules = [];
foreach (config('app.locales') as $locale) {
$rules["title.$locale"] = [
'nullable', 'string', 'max:255',
UniqueTranslationRule::for('articles', 'title')
->ignore($this->route('article')),
];
}
return $rules;
}
Wildcards work. 'title.*' => ['nullable', 'string', UniqueTranslationRule::for('articles')] is valid because Laravel expands the wildcard to title.en, title.fr, etc. before invoking the rule, and the rule reads the resolved attribute name. This is the shortest way to express "every locale of this column must be unique."
Compose with normal rules and translated messages. The rule returns the standard validation.unique message, so the :attribute will be the dotted key (title.en). Add friendly names in your form request:
public function attributes(): array
{
return [
'title.en' => 'English title',
'title.fr' => 'French title',
];
}
Wrap it for tenant or scope constraints. Because the rule is a plain Rule object, the cleanest extension point is your own rule class that delegates to the package one and then applies an extra where() on the underlying query — useful for multi-tenant apps where uniqueness should only be enforced within an account_id. Read the package rule first; if its internals are not exposed for this, a small custom rule using where('title->en', $value)->where('account_id', $id) is often simpler than fighting the abstraction.
Treat it as part of a two-layer strategy. Validate in the form request for friendly error messages, and additionally protect against races with whatever the storage engine allows (see Gotchas). In tests, assert the dotted key: $response->assertJsonValidationErrors('title.en').
It is a SELECT, not a constraint. Two concurrent requests can both pass validation and both insert. If the value truly must be unique, add a database-level guarantee where possible. For a JSON column that usually means a generated/virtual column per locale (or per hot locale) with a unique index on it.
No index means a scan. where('title->en', ...) cannot use an ordinary B-tree index on the JSON column. On small tables this is invisible; on a table with hundreds of thousands of rows and a busy form, it is a full scan per submitted locale per request. Generated columns with indexes are the standard fix.
Driver behaviour differs, especially on case and collation. MySQL/MariaDB JSON extraction comparisons generally follow the connection collation and are often case-insensitive; PostgreSQL's ->> is case-sensitive. Normalise slugs (Str::slug) before validating rather than relying on database collation, and test on the driver you actually deploy to.
Soft deletes and global scopes are not applied. The rule builds a raw query against the table; it does not run the model's global scopes. If soft-deleted rows should not block reuse of a title, you need to exclude them explicitly (via a custom rule or an extra where). Verify this against the rule source in your installed version before relying on either behaviour.
Empty strings collide with each other. With nullable, a submitted empty string is still a value and will match another empty string in the same locale. Laravel's ConvertEmptyStringsToNull middleware handles this for typical form posts; for JSON/API payloads, convert empty strings to null yourself or the second record to leave a locale blank will fail validation.
Dotted attribute names can confuse the rest of your validation. title.en is fine for the rule, but make sure other rules (max, string) are applied to the same dotted key and not to title as a whole, where they would be meaningless on an array.
Nested keys beyond one level are out of scope. If your JSON looks like {"meta": {"title": {"en": "..."}}} or your locale keys contain dots, the attribute-splitting logic will not produce the path you expect. Flatten the input or pass explicit paths after checking the source.
ignore() and non-standard keys. If your model uses a non-id primary key or a composite key, check how ignore() resolves the value; passing the full model is safer than passing an integer.
Locale is taken from the attribute suffix. A field named title_en (underscore) will not be understood; the rule expects the dotted column.locale form. Keep form field names aligned with that convention, or pass the column explicitly and validate a separate locale field.
Version pinning matters more than usual for a small package. Last release was April 2024; check the composer.json constraint against your Laravel version before adopting, and glance at the changelog if you are on Laravel 11+ or on an older LTS.
How can I help you explore Laravel packages today?