laravel-localization maintained by ismailnakkar
Laravel Localization
Localized routes and per-visitor language for Laravel: /terms and /fr/terms from one route, a switcher, and, with
laravel-seo 0.5+, canonical, hreflang and sitemap entries per copy
automatically. Requires PHP 8.4+ and Laravel 12.61.1+ or 13.12+. From laravel-seo 0.4? Follow its
UPGRADE.md.
Usage
composer require ismailnakkar/laravel-localization
php artisan vendor:publish --tag=localization-config
Set 'locales' => ['en', 'fr', 'es'] (default first) in config/localization.php and wrap the translated pages:
Route::localized(function () {
Route::get('/', HomeController::class)->name('home');
Route::get('terms', [PageController::class, 'terms'])->name('terms');
});
Render <html lang="{{ app()->getLocale() }}">, add the switcher and run php artisan localization:check on deploy.
- Call
Route::localized()outside prefix groups, before catch-all and fallback routes. Inside, prefix withRoute::prefix()->group(), never a route-level->prefix(). - Only put pages translated into every language (and their forms' POST routes) inside: every copy is listed in hreflang and the sitemap.
- Link with
route();url()and hard-coded paths lead to the default language. - On
/fr/terms,Route::currentRouteName()androuteIs()seeterms;route:listshowslocalization.fr.terms. - With
enthe default,/en/…301s to the bare URL only for localized GET pages; other/en/…paths fall through to your fallback, else 404. Register your own/en/…routes beforeRoute::localized(). - An unmatched URL's 404 renders in
config('app.locale')unless aRoute::fallback()catches it.
Language
- A localized page renders its URL's language. Other pages in
webuse the account's, else the one picked (switcher or suggestion), else the last copy opened, elseAccept-Language, else the default. - Opening a copy (page load, Inertia,
wire:navigate, link from another site) makes it the last one opened; the bare default copy counts only when reached from inside the site. Signed links and Livewire updates don't, nor (in browsers sending Fetch Metadata)<img>and iframes. Prefetch counts: exclude links to other languages from it (e.g.data-turbo-prefetch="false"). - Arriving from outside the site (another site, a bookmark, a typed URL) on a localized page's default copy 302s to the
account's language, else the picked one; never to
Accept-Language's, which is only suggested. Typing/en/…opens the default copy regardless. Crawlers, signed links and internal clicks are exempt. Don't let a CDN cache localized pages' HTML. - On Laravel 13, a POST catch-all inside
Route::domain()shadows the switcher'sPOST /locale.
The switcher saves the language to the session (and, with user_locale, the account) and returns to the same page in
that language. A signed page is re-signed only if its route is named and its signature is valid under the current
app.key, else it comes back unchanged. Labels are languages.{code} in their own language ('fr' => 'Français' in
lang/fr/languages.php):
@inject('localization', \Localization\Localization::class)
<form method="POST" action="{{ route('localization.switch') }}">
@csrf
<input type="hidden" name="to" value="{{ request()->getRequestUri() }}">
@foreach ($localization->languages() as $language)
<button name="locale" value="{{ $language->code }}" lang="{{ $language->code }}" @if ($language->current) aria-current="true" @endif>{{ __("languages.{$language->code}", locale: $language->code) }}</button>
@endforeach
</form>
Account language
Set user_locale to your users' language column. A member's account language outranks the session, and the switcher
saves it; an account without one gets the language on screen once (a value outside locales counts as none and is overwritten);
models without the column are skipped. Implement HasLocalePreference returning that column so mail uses it.
A visitor who told us no language (no account language, nothing picked) and whose browser prefers another gets a suggestion. Render this in your layout (not error views); either answer is a pick, so it isn't asked again:
@inject('localization', \Localization\Localization::class)
@if ($suggestion = $localization->suggestion())
<aside lang="{{ $suggestion->code }}">
<form method="POST" action="{{ route('localization.switch') }}">
@csrf
<input type="hidden" name="to" value="{{ request()->getRequestUri() }}">
<p>{{ __('Show this site in :language?', ['language' => __("languages.{$suggestion->code}", locale: $suggestion->code)], $suggestion->code) }}</p>
<button name="locale" value="{{ $suggestion->code }}">{{ __('Yes', locale: $suggestion->code) }}</button>
<button name="locale" value="{{ app()->getLocale() }}">{{ __('No, thanks', locale: $suggestion->code) }}</button>
</form>
</aside>
@endif
The default save fires model events. To save the column your own way, call this in a service provider's
boot(Localization $localization); the closure gets the signed-in model of any guard whose row has the column
(type-hint accordingly) and must return early while impersonating:
$localization->saveUserLocaleUsing(fn (User $user, string $code) => app(Users::class)->setLocale($user, $code));
Middleware
With two or more locales and remember_locale on, ResolveLocale (right after StartSession, so CSRF and throttle
errors are translated) and ApplyLocale (right after \Illuminate\Contracts\Session\Middleware\AuthenticatesSessions,
as it reads the user) join web. ApplyLocale must run after your session check:
- Session check not in your priority list by name or via
AuthenticatesSessions(Sanctum's lacks it)? List it:$middleware->appendToPriorityList(\Illuminate\Contracts\Auth\Middleware\AuthenticatesRequests::class, YourSessionCheck::class). - Ranked elsewhere?
$middleware->appendToPriorityList(YourSessionCheck::class, \Localization\Http\ApplyLocale::class). - Livewire: add
\Localization\Http\ResolveLocale::classand\Localization\Http\ApplyLocale::classtoLivewire::addPersistentMiddleware().
With 'remember_locale' => false, only a copy's URL sets the language (no session, account, switcher, entry redirect
or suggestion); route(), languages() and laravel-seo still work. Pick $code in your own middleware (with Livewire,
add it to Livewire::addPersistentMiddleware()) and call
app()->setLocale(\Localization\LocalizedRoute::of($request->route())->locale ?? $code). To redirect old ?lang=fr
URLs, check $code is one of your locales, then redirect to
LocalizedRoute::of($request->route())?->path($request->getPathInfo(), $code).
Configuration
| Key | Default | |
|---|---|---|
locales |
[] |
Language codes, default first. Fewer than two turns everything off. |
remember_locale |
true |
false: only a copy's URL sets the language. |
user_locale |
null |
The users table's language column. null: session only. |
Codes are ISO 639-1 plus an optional script and region, cased exactly (en, en-GB, zh-Hant). es-419 and fil
are refused, as Google ignores them in hreflang: use es, tl. A code is also the URL segment and app locale, so name
translation folders after it (lang/pt-BR/, not pt_BR).
localization:check exits 1 on any FAIL. Its rows: leftover keys (no laravel-seo 0.4 language keys left in
config/seo.php), entry_redirect (WARN while this 0.1 key is still set), user_locale (the default guard's users
table has the column; WARN when it can't check).