Translating Laravel Validation Messages (And Keeping Them Up To Date)
Validation messages are the biggest blind spot in most Laravel localization projects. The views get translated because someone can see them. The validation file doesn't, because it lives in lang/en/validation.php, nobody opens it, and the messages only appear when a user does something wrong — often in a language nobody on the team reads.
The file is also large. Laravel's stock validation.php carries well over a hundred rule messages, and several of them aren't single strings. Here's how the whole thing fits together and how to keep it current as your forms change.
Getting the file in the first place
Laravel 12 doesn't ship lang/ in a fresh app. The framework falls back to its own bundled English strings, which is why validation works fine before you've published anything. To customise or translate, publish first:
php artisan lang:publish
That writes lang/en/validation.php, auth.php, pagination.php and passwords.php into your project. Now copy the directory for each language you support:
cp -r lang/en lang/es
One important consequence of the fallback: if lang/es/validation.php is missing a key that lang/en/validation.php has, Laravel resolves it through your configured fallback locale rather than erroring. Missing translations therefore appear as English text mixed into a Spanish form — which is much easier to ship than to notice.
The four things inside validation.php
1. Rule messages
The bulk of the file. One entry per rule, using :attribute for the field name:
// lang/es/validation.php
'required' => 'El campo :attribute es obligatorio.',
'email' => 'El campo :attribute debe ser una dirección de correo válida.',
'unique' => 'El valor del campo :attribute ya está en uso.',
2. Nested rule messages
Some rules vary by the type of the value being validated, and these are arrays, not strings. size, min, max, between and gt/gte/lt/lte all take this shape:
'size' => [
'array' => 'El campo :attribute debe contener :size elementos.',
'file' => 'El campo :attribute debe tener :size kilobytes.',
'numeric' => 'El campo :attribute debe ser :size.',
'string' => 'El campo :attribute debe tener :size caracteres.',
],
This is where naive translation tooling breaks. Flatten size to a single string and every size validation error in that language throws, because Laravel expects to index into an array by type. If you're moving these files through a spreadsheet or a converter, verify the nesting survives the round trip — our PHP-to-JSON converter preserves nested keys precisely because this structure is so easy to lose.
3. custom — per-field overrides
For when one field needs different wording from the generic rule. The nesting is field, then rule:
'custom' => [
'email' => [
'unique' => 'Ya existe una cuenta con este correo. ¿Quieres iniciar sesión?',
],
'password' => [
'min' => 'La contraseña necesita al menos :min caracteres.',
],
],
These take priority over the generic rule message. It's the right place for copy that does real work — an "already registered, want to log in?" message converts better than a bare uniqueness error, and it's a genuine product decision rather than a mechanical translation.
4. attributes — readable field names
:attribute is filled with the field name, snake-case converted to spaced words. email_address becomes "email address", which is passable in English and useless in every other language, because the field name is still English:
// Without this, Spanish users read "El campo email address es obligatorio."
'attributes' => [
'email_address' => 'correo electrónico',
'first_name' => 'nombre',
'dob' => 'fecha de nacimiento',
],
This array ships empty. Skipping it is the single most common reason a "fully translated" app still shows English words inside Spanish validation errors. Nested fields use dot notation, and wildcards work:
'attributes' => [
'billing.postcode' => 'código postal',
'items.*.quantity' => 'cantidad',
],
Form request messages: convenient, and a localization trap
Overriding messages inside a form request is idiomatic Laravel, and it's also how untranslatable strings get into your app:
// Don't do this — the string is now unreachable for translators.
public function messages(): array
{
return [
'email.unique' => 'That email is already taken.',
];
}
Anything hardcoded here bypasses your lang files entirely. Either move the copy to custom in validation.php and delete the method, or keep the method and have it reference translation keys:
public function messages(): array
{
return [
'email.unique' => __('validation.custom.email.unique'),
];
}
The first option is better — it's less code, and it keeps every message in one file where a translator can find it. Reach for messages() only when the copy genuinely depends on request state.
The same rule applies to attributes():
// Prefer the 'attributes' array in validation.php over this.
public function attributes(): array
{
return ['dob' => __('validation.attributes.dob')];
}
Custom rules need their own keys
A custom rule class holds its own failure message, and it's easy to hardcode:
final class StrongPassword implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! preg_match('/[A-Z]/', $value)) {
// Hardcoded — invisible to your translators.
$fail('The password must contain an uppercase letter.');
}
}
}
Pass a translation key instead. $fail() accepts one and resolves it, and translate() lets you supply replacements:
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! preg_match('/[A-Z]/', $value)) {
$fail('validation.custom_rules.password_uppercase')->translate();
}
if (mb_strlen($value) < $this->min) {
$fail('validation.custom_rules.password_length')->translate([
'min' => $this->min,
]);
}
}
// lang/es/validation.php
'custom_rules' => [
'password_uppercase' => 'La contraseña debe contener una letra mayúscula.',
'password_length' => 'La contraseña debe tener al menos :min caracteres.',
],
Don't translate the base file by hand
Laravel's own validation messages have already been translated, well, by the community. The Laravel-Lang/lang package maintains them across a large set of locales under an MIT licence, and it tracks framework releases so new rules get covered.
composer require --dev laravel-lang/common
The package then installs and updates locale files for the languages you choose — see its README for the current commands, which change between major versions.
Use it for the stock messages, and spend your own effort on the parts nobody can do for you: attributes (your field names), custom (your product copy) and your custom rules. That split is usually the difference between "translating validation" being a two-hour job and a two-week one.
Keeping it from drifting
The messages that break are rarely the ones you translated at the start. They're the ones added afterwards — a new form, a new field, a new rule — where English lands in lang/en and nowhere else. Because Laravel falls back silently, nothing tells you.
Three habits that catch it:
Diff the key sets in CI. A test comparing key structures across locales fails the build when a language falls behind:
it('has matching validation keys in every locale', function () {
$reference = Arr::dot(require lang_path('en/validation.php'));
foreach (['es', 'fr', 'de'] as $locale) {
$translated = Arr::dot(require lang_path("{$locale}/validation.php"));
expect(array_keys($translated))
->toEqualCanonicalizing(array_keys($reference), "Locale {$locale} is out of sync");
}
});
Arr::dot() matters here — it flattens the nested size/custom/attributes structures so you compare real leaf keys rather than just the top level.
Review error states in the target language. Set the locale, submit the form empty, read what comes back. Ten minutes per language finds more than any audit, and it's the only way to catch messages that are grammatically valid but wrong in context.
Check what's actually reaching users. Missing keys render as the key itself in some setups and as fallback English in others. Our untranslated key detector takes a URL and reports both, which is a quick way to confirm a form page is clean without reading the templates.
Summary
- Run
php artisan lang:publishfirst; Laravel 12 ships nolang/directory. size,min,max,betweenand the comparison rules are arrays keyed by type. Flattening them causes runtime errors.- Fill in
attributes— it's empty by default and its absence leaves English field names inside translated messages. - Keep copy out of
messages()in form requests, and out of$fail()in custom rules. Use keys and->translate(). - Take the stock messages from Laravel-Lang rather than translating 100+ strings yourself.
- Silent fallback means drift is invisible. Diff key sets in CI.
Managing four parallel validation.php files by hand works until it doesn't — usually around the third language, or the first time someone resolves a merge conflict in one locale and not the others. LangSyncer imports your existing files, shows coverage per language so a missing key is visible instead of silently falling back, and publishes fixes to production in seconds. Which is useful, because validation copy is exactly the kind of thing you want to correct the moment a user reports it, not at the next deploy.