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'],
];
{
    "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, so Late fee: 12,50 € mixed English text with a Spanish number. French has no files at all, so everything came from fallback_locale (en). A key that exists nowhere, No such key, is returned unchanged.

  • Placeholder case is copied. :Name capitalised the value. A JSON entry is looked up by its exact text, though, and es.json only had Welcome, :name!, so the :Name version stayed English.

  • Attribute names need their own translation. title became título through validation.attributes, while isbn stayed as the raw field name.

  • Formatting is separate from translation. Number and 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;
    }
}
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/lang package provides validation.php, auth.php and 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
});

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 to lang/ in Laravel 9. Since Laravel 11 the directory only exists after lang:publish.

  • trans() and @lang still work, but __() and trans_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() and money_format(): money_format() was removed in PHP 8. Number wraps NumberFormatter from intl.