Language files: where translations live
Laravel reads translations from the lang directory. A new application does not include it; running the publish command creates it with the framework's own English strings for validation, authentication, pagination and passwords.
There are two formats and you can use both. PHP files return nested arrays and are addressed as file.key, which suits strings you want grouped. JSON files, one per locale, are keyed by the source sentence itself, which suits apps with a lot of copy where naming every string would slow you down.
php artisan lang:publish
lang/
├── en/
│ ├── messages.php // __('messages.welcome')
│ └── validation.php
├── es/
│ ├── messages.php
│ └── validation.php
└── es.json // __('Save changes')
Retrieving strings: __(), @lang and placeholders
The __() helper looks a key up in the active locale and falls back to the fallback locale when it is missing. If neither has it, you get the key back, which is why a missing translation shows up on the page as messages.welcome instead of an error.
Placeholders start with a colon and are replaced from the second argument. Writing the placeholder capitalized or in upper case changes the case of the value to match. In Blade, prefer {{ __() }} over @lang: the directive prints the string without escaping it.
// lang/en/messages.php
return [
'welcome' => 'Welcome back, :name',
];
// Anywhere in PHP
__('messages.welcome', ['name' => $user->name]);
{{-- Blade --}}
<h1>{{ __('messages.welcome', ['name' => $user->name]) }}</h1>
Setting the locale and building a language switcher
The default and fallback locales come from APP_LOCALE and APP_FALLBACK_LOCALE. To serve a different language per request, call App::setLocale() early, in middleware, from something stable: a URL segment, the authenticated user's preference or a subdomain.
A URL segment is the choice that search engines can index, because every language gets its own address. A language switcher is then a list of ordinary links to the same page under each locale prefix. Storing the locale only in the session works for a logged-in dashboard and hides every language but the default from crawlers.
// app/Http/Middleware/SetLocale.php
public function handle(Request $request, Closure $next): Response
{
$locale = $request->route('locale');
if (in_array($locale, config('app.available_locales'), true)) {
App::setLocale($locale);
URL::defaults(['locale' => $locale]);
}
return $next($request);
}
// routes/web.php
Route::prefix('{locale}')
->whereIn('locale', config('app.available_locales'))
->middleware(SetLocale::class)
->group(function () {
Route::get('/pricing', PricingController::class)->name('pricing');
});
Pluralization with trans_choice()
English has two plural forms. Polish and Ukrainian have three in everyday use, Arabic has six, and Chinese, Japanese and Vietnamese have one. trans_choice() picks the right segment of a pipe-separated string using the plural rules of the active locale, so each language file must supply as many forms as that language needs.
This is the part of localization most often wrong in production, because a two-form string in a three-form language still renders something. The per-language guides below list the exact forms Laravel expects for each language.
// lang/en/messages.php
'apples' => '{0} No apples|{1} One apple|[2,*] :count apples',
// lang/pl/messages.php (one, few, many)
'apples' => ':count jabłko|:count jabłka|:count jabłek',
trans_choice('messages.apples', 5); // "5 jabłek" when the locale is pl
Dates, numbers and validation messages
Translated strings are half of localization. Dates follow the application locale through Carbon: isoFormat() and translatedFormat() produce month and day names in the active language, while format() never does. Numbers and currency go through the Number helper, which takes a locale and applies the right separators and symbol position.
Validation messages live in lang/{locale}/validation.php, including an attributes array that replaces field names such as email_address with readable labels. Leaving that array empty is the reason translated forms show English field names inside otherwise translated sentences.
now()->isoFormat('LL'); // "9 de octubre de 2026" in es
Number::currency(1234.5, in: 'EUR', locale: 'de'); // "1.234,50 €"
// lang/es/validation.php
'attributes' => [
'email_address' => 'correo electrónico',
],
Localization practices that hold up
Never build a sentence by concatenating translated fragments: word order differs between languages, so pass values in as placeholders and translate the whole sentence. Keep keys stable and descriptive, and do not reuse one key in two contexts just because the English text happens to match.
Find hardcoded text before translating, not after: every string left in a Blade view is a string no translator will ever see. Test with a locale that stresses the layout, such as German for length or Arabic for right-to-left. And decide early who changes copy after launch, because if the answer is a developer and a deploy, every typo becomes a release.