Laravel localization guide

Laravel translation files, the two lookup styles, pluralization with trans_choice, locale middleware, and syncing PHP and JSON language files from CI.

Laravel supports two translation systems side by side, which is the first thing to understand and the source of most confusion.

The two systems

Short-key PHP arrays, in a per-locale directory:

php
// lang/en/messages.php
return [
    "welcome" => "Welcome back, :name",
    "dashboard" => [
        "title" => "Dashboard",
    ],
];
php
__("messages.welcome", ["name" => $user->first_name]);
__("messages.dashboard.title");

JSON files keyed by the source string, one per locale:

json
// lang/fr.json
{
  "Welcome back, :name": "Bon retour, :name"
}
php
__("Welcome back, :name", ["name" => $user->first_name]);

Both work. The trade-off:

Short keys stay stable when the English copy changes, namespace cleanly, and are the right choice for anything reused or long-lived. The cost is indirection — reading a template tells you a key, not what it says.

JSON string keys read beautifully in templates and are excellent for prototyping. Their fatal flaw appears the first time you edit the English: the key is the old English, so changing “Welcome back” to “Welcome back!” orphans every translation of it, silently, in every language.

For anything that will live longer than a sprint, use short keys. Mixing both in one project is fine and common — JSON for one-off marketing copy, short keys for the application.

Note the location changed: lang/ at the project root from Laravel 9, resources/lang/ before that.

Placeholders

Laravel uses a leading colon:

php
"welcome" => "Welcome back, :name",
"invitation" => ":inviter invited you to :project",

Capitalisation of the placeholder controls capitalisation of the substituted value — :Name produces John, :NAME produces JOHN. Clever, and worth knowing about because it means :name and :Name are not interchangeable.

The syntax has one sharp edge: a placeholder followed immediately by a letter or underscore is ambiguous, so :name_suffix parses as one placeholder. Keep a separator after placeholders, or rename them.

Pluralization

php
// lang/en/messages.php
"messages" => "{0} No messages|{1} One message|[2,*] :count messages",
php
trans_choice("messages.messages", $count, ["count" => $count]);

Forms are pipe-separated, with optional exact values in braces and ranges in brackets. Without explicit ranges, Laravel falls back to two forms — singular then plural — selected by its own pluralizer.

This is the weak point for serious localization. Laravel’s built-in pluralizer handles a two-form model well and languages with four or six forms poorly. Polish, Arabic, Russian and Welsh need explicit range notation per language, hand-written, and getting a boundary wrong produces a bug that only appears at particular numbers.

If you support those languages, the practical options are explicit ranges maintained carefully per locale, or moving those specific messages to an ICU MessageFormat library that expresses plural categories by name and delegates the rules to CLDR. See plural rules by language for what each language actually requires.

Setting the locale

From middleware, registered early:

php
class SetLocale
{
    public function handle(Request $request, Closure $next)
    {
        $locale = $request->segment(1)
            ?? $request->user()?->locale
            ?? $request->getPreferredLanguage(config("app.supported_locales"));

        if (in_array($locale, config("app.supported_locales"), true)) {
            App::setLocale($locale);
        }

        return $next($request);
    }
}

Two details. Validate against a whitelist — App::setLocale() with unvalidated user input is a path traversal waiting to happen, since the locale becomes a directory name. And set a fallback_locale in config/app.php so a missing key degrades to your default language rather than rendering the raw key to a user.

Blade

blade
<h1>{{ __("messages.dashboard.title") }}</h1>
<p>{{ __("messages.welcome", ["name" => $user->first_name]) }}</p>

@lang("messages.dashboard.title")

{{ }} escapes output, which is what you want. If a translation legitimately contains markup, {!! !!} renders it raw — and that is a decision to make deliberately, because it means a translator can inject HTML into your page.

The better pattern for a sentence containing a link is to keep the markup out of the translation entirely, passing the tags as placeholders:

php
"terms" => "Read our :openterms of service:close before continuing.",

Clumsy, and safer than putting an anchor tag in a file that translators edit.

Validation messages

Laravel’s validation messages are translatable out of the box in lang/en/validation.php, including per-attribute overrides:

php
"attributes" => [
    "email" => "email address",
],
"custom" => [
    "email" => [
        "required" => "We need an email address to send your receipt.",
    ],
],

Worth setting up early. Retrofitting means auditing every custom rule and message in the application.

What gets forgotten

  • Mail notifications. Queued mail runs without a request, so the locale must be passed explicitly. Notification::locale() and the HasLocalePreference contract on your user model both handle this.
  • Queued jobs generally. Same problem, same fix.
  • PDF and export generation.
  • API error responses, if a user ever sees them.
  • Enum and status labels, where Str::title($status) is doing the work a translation should.

Syncing translations

Laravel’s PHP array files are a supported format, so they sync directly:

bash
wti push      # lang/en/*.php and lang/en.json up
wti pull      # translated files back
wti diff      # what a push would change

WebTranslateIt parses the PHP array format natively, including magic comments for per-string notes to translators — which is the right place to record that :name holds a first name only, or that a string has a length limit. See visual context for translators for why that matters more than it appears.

Push on merge to your designated branch, pull nightly into a pull request; Git-based localization workflows covers the branch strategy.

Localizing URLs and models

Route segments. Laravel has no built-in translated routing, so the usual pattern is a locale prefix group:

php
Route::prefix("{locale}")
    ->whereIn("locale", config("app.supported_locales"))
    ->middleware(SetLocale::class)
    ->group(function () {
        Route::get("dashboard", DashboardController::class)->name("dashboard");
    });

Then bind the locale by default so route("dashboard") does not need it passed every time:

php
URL::defaults(["locale" => app()->getLocale()]);

Without that default, every route() call in every view needs the parameter, and the one you forget throws in production.

Translatable model content is a different problem from interface strings — user-entered data rather than developer copy, so it belongs in the database rather than in language files. The two common shapes are a JSON column per translatable attribute, or a separate translations table with a row per locale. JSON columns are simpler and query poorly; a translations table is more work and lets you index and search per language. Packages exist for both; the decision to make first is whether you need to search translated content, because that answer settles it.

Do not put user-generated content in language files. It is not translatable copy, it changes at runtime, and it will fight your sync.

Testing

php
public function test_dashboard_renders_in_french(): void
{
    App::setLocale("fr");

    $this->get("/fr/dashboard")->assertOk();
}

Assert on behaviour rather than on translated strings, so a copy revision does not break the suite.

The check worth automating is completeness: every key present in lang/en present in every other locale, no key left as its own translation, and every trans_choice string carrying the form count its language requires. Laravel’s pluralizer failing quietly for languages with more than two forms makes that last one the highest-value assertion in the set.

Common mistakes

  • JSON string keys for copy that will be edited, orphaning every translation on the first wording change.
  • App::setLocale() on unvalidated input.
  • Relying on the default pluralizer for languages with more than two forms.
  • No fallback_locale, so a missing key renders as messages.dashboard.title to a user.
  • Markup inside translation strings, rendered with {!! !!}.
  • Forgetting the locale on queued mail and jobs.

Frequently asked questions

Where are Laravel language files stored?
In lang/ at the project root for Laravel 9 and later, and resources/lang/ before that. Short-key PHP array files live in a per-locale subdirectory such as lang/fr/messages.php, while JSON translation files sit directly in lang/ as lang/fr.json.
What is the difference between __() and trans_choice()?
__() retrieves a single translation. trans_choice() selects between plural forms based on a count, using the pipe-separated forms and optional range notation in the translation string.
Should I use short keys or the full string as the key?
Short keys for anything reused or likely to change, because the key stays stable when the copy is edited. Full-string JSON keys are convenient for one-off text but become stale the moment the English wording is revised, since the key itself is the old wording.
How do I set the locale per request in Laravel?
Call App::setLocale() from middleware registered early in the stack, resolving the locale from the URL segment, the authenticated user's preference or the Accept-Language header. Setting it in a controller is too late for anything resolved earlier in the request.

Keep reading

Translate your app without the spreadsheet round-trip

WebTranslateIt reads the file formats and placeholder syntax described on this page, validates them as translators work, and syncs the results straight back into your repository.