Localization
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Laravel 13.x (13.34.0) on PHP 8.3 to 8.5, checked on 2026-10-05. Bookshelf serves
English and Spanish. The text comes from translation files, and dates, numbers and money are formatted for the
locale by Carbon and PHP’s intl extension. Mail templates are localised the same way
(Mail and Notifications).
Translation files
A new application has no lang directory. php artisan lang:publish creates lang/en with the framework’s own
strings (auth.php, pagination.php, passwords.php, validation.php). Application strings go in one of two
formats:
-
PHP files with short keys,
lang/{locale}/{file}.php, read as__('loans.due'). They group strings by feature and suit larger applications. -
JSON files keyed by the default-language text,
lang/{locale}.json, read as__('Welcome, :name!'). The English text is the key, so the code stays readable and a missing translation shows English.
<?php
// lang/en/loans.php
return [
'due' => 'Due :date',
'overdue' => '{0} Nothing overdue|{1} One book is overdue|[2,*] :count books are overdue',
'fee' => 'Late fee: :amount',
];
// lang/es/loans.php (no 'fee' yet)
return [
'due' => 'Vence el :date',
'overdue' => '{0} Nada vencido|{1} Un libro vencido|[2,*] :count libros vencidos',
];
// lang/es/validation.php (two entries; the rest falls back to English)
return [
'required' => 'El campo :attribute es obligatorio.',
'attributes' => ['title' => 'título'],
];
Source: Laravel — Defining Translation Strings and Pluralization
{
"Welcome, :name!": "¡Bienvenida, :name!",
"Your loan of :title": "Tu préstamo de :title"
}
Using them
<?php
$due = Carbon::parse('2026-10-26');
foreach (['en', 'es'] as $locale) {
App::setLocale($locale);
__('loans.due', ['date' => $due->locale($locale)->isoFormat('LL')]);
array_map(fn ($n) => trans_choice('loans.overdue', $n), [0, 1, 3]);
__('loans.fee', ['amount' => Number::currency(12.5, 'EUR', $locale)]);
__('Welcome, :name!', ['name' => 'ana']);
__('Welcome, :Name!', ['name' => 'ana']);
Validator::make([], ['title' => 'required', 'isbn' => 'required'])->errors();
$due->locale($locale)->diffForHumans(Carbon::parse('2026-10-05'));
Number::format(1234567.891, 2, locale: $locale);
Number::spell(21, locale: $locale);
}
[en] Due October 26, 2026 | Nothing overdue / One book is overdue / 3 books are overdue | Late fee: €12.50 | Welcome, ana! | Welcome, Ana!
{"title":["The title field is required."],"isbn":["The isbn field is required."]}
3 weeks after | 1,234,567.89 | twenty-one
[es] Vence el 26 de octubre de 2026 | Nada vencido / Un libro vencido / 3 libros vencidos | Late fee: 12,50 € | ¡Bienvenida, ana! | Welcome, Ana!
{"title":["El campo título es obligatorio."],"isbn":["El campo isbn es obligatorio."]}
3 semanas después | 1.234.567,89 | veintiuno
[fr] Due x | No such key
The run shows how lookups behave:
-
Missing strings fall back silently. Spanish has no
loans.fee, soLate fee: 12,50 €mixed English text with a Spanish number. French has no files at all, so everything came fromfallback_locale(en). A key that exists nowhere,No such key, is returned unchanged. -
Placeholder case is copied.
:Namecapitalised the value. A JSON entry is looked up by its exact text, though, andes.jsononly hadWelcome, :name!, so the:Nameversion stayed English. -
Attribute names need their own translation.
titlebecametítulothroughvalidation.attributes, whileisbnstayed as the raw field name. -
Formatting is separate from translation.
Numberand Carbon used the locale passed to them (12,50 €,3 semanas después), even in a string that was not translated.
JSON wins over a PHP key with the same name. For one run, es.json also held the key "loans.due".
__('loans.due') in Spanish then returned that JSON value instead of lang/es/loans.php. Short keys and JSON keys
share one namespace, so JSON keys should be real sentences, never dotted names.
Missing keys can be logged instead of guessed at:
<?php
// AppServiceProvider::boot()
Lang::handleMissingKeysUsing(function (string $key, array $replace, ?string $locale) {
Log::warning('missing translation', ['key' => $key, 'locale' => $locale]);
return $key;
});
In Spanish, __('Checkout') reported missing: Checkout (es). __('loans.fee') did not report anything, because
the fallback found it in English. The callback catches keys missing in every locale. A test that compares the files
catches gaps in one locale (see below).
Choosing the locale
config/app.php sets locale and fallback_locale (from APP_LOCALE and APP_FALLBACK_LOCALE), and
App::setLocale() changes it per request. Bookshelf’s SetLocale middleware from
Middleware negotiates with the browser:
<?php
// app/Http/Middleware/SetLocale.php, used as ->middleware('locale:en,es')
class SetLocale
{
public function handle(Request $request, Closure $next, string ...$supported): Response
{
$locale = $request->getPreferredLanguage($supported); // best match from Accept-Language, else the first
app()->setLocale($locale);
$response = $next($request);
$response->headers->set('Content-Language', $locale);
return $response;
}
}
Source: Laravel — Configuring the Locale
Accept-Language: es-ES,es;q=0.9,en;q=0.8 -> ¡Bienvenida, Ana! 2 libros vencidos [Content-Language: es]
Accept-Language: es-MX -> ¡Bienvenida, Ana! 2 libros vencidos [Content-Language: es]
Accept-Language: fr-FR,fr;q=0.9 -> Welcome, Ana! 2 books are overdue [Content-Language: en]
Accept-Language: en-GB,es;q=0.5 -> Welcome, Ana! 2 books are overdue [Content-Language: en]
Accept-Language: fr,es;q=0.1 -> ¡Bienvenida, Ana! 2 libros vencidos [Content-Language: es]
Accept-Language: (none) -> Welcome, Ana! 2 books are overdue [Content-Language: en]
es-MX matched the plain es locale. fr,es;q=0.1 chose Spanish, the only supported language in the list, even
at a low weight. The response had no Vary: Accept-Language header, so a CDN or reverse proxy could serve the
Spanish page to an English visitor. A middleware that negotiates should add Vary, or the locale should be in the
URL (/es/libros) or in a user setting. A URL prefix is also what search engines index separately. When the user
has chosen a language, store it on the user or in the session, and let it win over the header.
What Laravel does not translate
-
Database content such as book descriptions needs columns per locale or a JSON column, for example with
spatie/laravel-translatable. -
Framework strings in other languages: Laravel ships English only. The community
laravel-lang/langpackage providesvalidation.php,auth.phpand the other framework files for many locales. -
Right-to-left layouts are a CSS concern, starting with
<html dir="rtl">. -
Vendor views such as the mail layout need publishing before their text can be translated.
Testing
<?php
// tests/Feature/LocalizationTest.php
test('the page follows Accept-Language', function (string $header, string $locale, string $text) {
$this->get('/welcome', ['Accept-Language' => $header])
->assertHeader('Content-Language', $locale)
->assertSee($text);
})->with([
'Spanish (Spain)' => ['es-ES,es;q=0.9', 'es', '¡Bienvenida, Ana!'],
'Mexican Spanish' => ['es-MX', 'es', 'libros vencidos'],
'unsupported French' => ['fr-FR', 'en', 'Welcome, Ana!'],
]);
test('the Spanish loan file lacks only the fee string', function () {
$missing = array_diff_key(require lang_path('en/loans.php'), require lang_path('es/loans.php'));
expect(array_keys($missing))->toBe(['fee']); // fails as soon as a new key is not translated
});
Source: Laravel — Making Requests
All four pass. The second test documents the known gap and fails as soon as someone adds an English key without Spanish, which the silent fallback would otherwise hide.
Where older books and tutorials are outdated
-
resources/lang(Stauffer ch. 6) moved tolang/in Laravel 9. Since Laravel 11 the directory only exists afterlang:publish. -
trans()and@langstill work, but__()andtrans_choice()are the documented helpers. -
Carbon::setLocale()called globally is not needed. Laravel sets Carbon’s locale with the application locale, and->locale($locale)sets it per instance. -
number_format()andmoney_format():money_format()was removed in PHP 8.NumberwrapsNumberFormatterfromintl.