niels-numbers/laravel-localizer
Locale-aware routing for Laravel and a successor to mcamara/laravel-localization. Registers every route twice as static routes for route:cache support, auto-detects Accept-Language, redirects visitors, and resolves route() per language.
Install and publish config
composer require niels-numbers/laravel-localizer
php artisan vendor:publish --tag=localizer-config # verify tag name in the install docs
Open config/localizer.php first — that is the file you will actually touch day-to-day. The keys you care about from the changelog are:
hide_default_locale — whether / or /en is canonical for your default localelocalizer.detectors — the ordered detector chain (UserDetector, BrowserDetector, plus any custom ones)Register the middleware on the web group
SetLocale resolves the active locale (URL → session → cookie → detectors → app.fallback_locale); RedirectLocale enforces the canonical prefixed/unprefixed form. Put SetLocale early (before anything that reads App::getLocale()), and RedirectLocale after it. Both skip routes not registered through the package's macros, so /api/health, /admin, and other plain routes in the same group are unaffected.
Wrap your routes
Route::localize(function () {
Route::get('/about', AboutController::class)->name('about');
Route::get('/contact', ContactController::class)->name('contact');
});
That's it. route('about') keeps working unchanged — the package swaps Laravel's UrlGenerator so it picks the with_locale.* or without_locale.* variant based on App::getLocale().
Verify with route:list before writing any code
php artisan route:list --name=about
# GET|HEAD about ................ without_locale.about
# GET|HEAD {locale}/about ....... with_locale.about
If you see only one row, your macro call isn't where you think it is (inside a Route::group with a conflicting prefix, or after a route:cache you forgot to clear).
First use case. A marketing site with /about plus /de/about, /fr/about: you want a German browser hitting /about to land on /de/about automatically, and you want to stop hand-writing app()->getLocale() in every href. That is the package's entire sweet spot. Start there, get it green, then add a language switcher and hreflang.
Route::localize() gives every route the same path under a {locale} prefix (/about, /de/about). Use it unless you genuinely need translated slugs.
Route::translate() gives locale-specific URIs (/about, /de/ueber, /fr/a-propos) driven by lang/{locale}/routes.php, keyed on the full URI. Every route inside it must be named — since 1.4.0 it throws UnnamedTranslatedRouteException at boot, because Route::localizedUrl() resolves the equivalent URL by shared route name and a nameless translated route can never be switched.
Route::translate(function () {
Route::get(__('routes.about'), AboutController::class)->name('about');
});
A switcher link to plain /about carries no locale signal, so RedirectLocale sends the user back to their session locale and the switcher appears dead. Use the always-prefixed helper:
@foreach (config('localizer.locales') as $locale)
<a href="{{ Route::localizedSwitcherUrl($locale) }}"
@class(['active' => app()->getLocale() === $locale])>
{{ strtoupper($locale) }}
</a>
@endforeach
Route::localizedSwitcherUrl($locale) — always prefixed, for in-page switchers.Route::localizedUrl($locale) — canonical URL of the current page in another locale; use this for <link rel="alternate" hreflang> and sitemap generation, not for switchers.Route::hasLocalized($name) / Route::isLocalized() — guard switcher rendering on pages that don't have a localized variant.<link rel="canonical" href="{{ Route::localizedUrl(app()->getLocale()) }}">
@foreach (config('localizer.locales') as $locale)
<link rel="alternate" hreflang="{{ $locale }}" href="{{ Route::localizedUrl($locale) }}">
@endforeach
Keep this in one Blade partial so the sitemap generator and the layout can't drift apart.
Detectors run only when there's no URL prefix, no session entry, and no cookie — i.e. on first visit or after cookie clear. A tenant/domain detector is a small class returning a locale or null; register it in localizer.detectors in the order you want precedence. UserDetector (reads an authenticated user's preference, typically via HasLocalePreference) should sit above BrowserDetector so a logged-in user's explicit choice beats their Accept-Language.
Supported locales are baked at boot (that's what keeps route:cache valid); active locales are a runtime subset:
// in tenant-identifying middleware
Localizer::setActiveLocales($tenant->locales);
Localizer::setActiveDefaultLocale($tenant->default_locale);
Localizer::activeLocales(); // current reachable set
Localizer::isActive('fr'); // guard switcher rendering
Localizer::defaultLocale(); // the effective default
setActiveDefaultLocale() is the multi-tenant escape hatch for "this tenant's default is de, so de is unprefixed" without mutating app.fallback_locale globally. UrlGenerator, RedirectLocale, SetLocale, and CurrentRouteLocalizer all read through Localizer::defaultLocale() — but TranslateMacro reads Config directly, because Route::translate()'s without_locale.* variant is baked at boot. Translated URIs cannot be re-mapped per request.
Don't copy an adapter into app/ any more — bind the shipped one in AppServiceProvider::register():
$this->app->bind(\Tighten\Ziggy\Ziggy::class, \NielsNumbers\LaravelLocalizer\Routing\LocalizerZiggyV2::class);
For Ziggy v1, you must rebind both LocalizerZiggyV1 and LocalizerBladeRouteGeneratorV1, because Ziggy v1 instantiates Ziggy directly inside its blade generator and bypasses the container. The spatie/laravel-typescript-transformer recipe was removed in 1.4.0 as incompatible (the generator indexes by controller rather than name, so the double-registered variants overwrite each other) — Ziggy is the working JS adapter.
Queue workers and console commands never see SetLocale, so app()->getLocale() is whatever config/app.php says. Propagate explicitly: Mail::to($user)->locale($locale), HasLocalePreference on the user model, or Localizable::withLocale() for queued jobs. This is the single most common source of "the email is in the wrong language" bugs.
Never declare $locale as the first controller argument. Since 0.10.0, SetLocale strips {locale} from the matched route's parameter bag after resolving it, so index($country = null) correctly receives null on /de/users rather than 'de'. Read the locale from App::getLocale() (or URL::defaults()), not from a positional parameter. If you added the $locale argument as a workaround on an older version, drop it now.
Name every route inside Route::localize(). Since 1.4.0, unnamed routes stay unnamed instead of inheriting the group's as prefix (previously they all collapsed onto the literal name with_locale., and only the first survived in the name lookup). Reachable by URL, invisible to route(). Cheap to fix, nasty to debug.
RedirectLocale deliberately skips non-safe methods. POST/PUT/PATCH/DELETE are not redirected, because a 302 → GET downgrade would silently drop the request body. If you're testing a form submission against a non-canonical URL, you will get your controller output instead of a redirect — that's intended. Test canonicalization with GET.
A redirect on first visit can break existing feature tests. Any test hitting /about with no session now gets a 302 to /de/about if the test client sends Accept-Language: de. Either assert the redirect explicitly (good — it documents behaviour), or set the locale in setUp() by seeding the session. Watch for tests that pass locally and fail in CI because CI's default headers differ.
Plain routes really do coexist. SetLocale and RedirectLocale detect package-registered routes via a locale_type action attribute and skip everything else. So /admin and /api/health in the web group won't get spurious redirects to non-existent /de/admin paths. If they do, you've registered the middleware somewhere that isn't the localizer middleware or you've set the attribute incorrectly in a custom macro.
Debugging checklist, in order:
php artisan route:list — do you see both with_locale.* and without_locale.* rows? If not, the macro isn't being hit.php artisan route:clear — a stale cached route table is the most common "my change did nothing".App::getLocale() in a middleware after SetLocale — if it's wrong there, the problem is the detector chain or session/cookie, not URL generation.request()->cookie(...)/session — a stale cookie beats every detector, which is correct but surprising when testing.localizedSwitcherUrl() and not localizedUrl() or a hand-built /about.route:cache is safe — but only for supported locales. The static-route design (two registrations per route) is exactly why this package exists versus mcamara/laravel-localization. Runtime setActiveLocales() doesn't invalidate the cache because the union is static; but adding a locale to config does require a route re-cache. Wire that into your deploy script.
Octane. setActiveLocales() / setActiveDefaultLocale() live on the Localizer singleton as request-scoped properties. Under a long-lived worker, reset them in a middleware that runs before your tenant resolver (or in the resolver itself, unconditionally) so one tenant's subset can't leak into the next request. Same discipline you'd apply to any container singleton.
Middleware order is load-bearing. SetLocale must precede anything reading the locale (view composers, UrlGenerator usage in a middleware, Inertia share data). RedirectLocale must follow SetLocale — it compares the request's canonical form against the resolved locale, so it needs a resolved locale to compare against.
Extension point worth knowing about: the Wayfinder pattern. For generated route modules, wrap the generated function in a localizedRoute() helper rather than patching generated output — generated files get blown away on every build.
Migration note. mcamara/laravel-localization is marked abandoned in Composer with this package set as the replacement, so composer require after removing the old one usually resolves cleanly. Read the migration guide anyway: LaravelLocalization::setLocale()-in-a-controller patterns have no direct equivalent — locale now resolves in middleware, and per-module Route::localize() beats a global call.
How can I help you explore Laravel packages today?