goodcat/laravel-l10n
A Laravel localization package for building multilingual apps, with helpers for locale detection, language switching, and localized routes/URLs. Lightweight and easy to integrate into existing Laravel projects.
Install and publish.
composer require goodcat/laravel-l10n
php artisan vendor:publish --tag=l10n-config
Two config surfaces matter now. config/l10n.php still defines the supported locale list, the default locale, whether the default locale is hidden from URLs, and which signals are used to detect a visitor's locale. As of v0.5.3 the resolved preference is stored under config('app.preferred_locales') — an array, replacing the old singular config('app.preferred_locale') string. Add that key to config/app.php (or let the package default it) and remove any old singular key, or you'll silently get no preferred locale.
Register the middleware. On Laravel 11+ add the locale middleware to the web group in bootstrap/app.php:
->withMiddleware(function (Middleware $middleware) {
$middleware->web(append: [
\Goodcat\L10n\Http\Middleware\SetLocale::class,
]);
});
On Laravel 10 and below, add the same class to app/Http/Kernel.php's web group. Register it early — before anything that resolves translations, validates input, or generates URLs.
Optionally add the new RedirectToPreferredLocale middleware after SetLocale. It performs a once-per-session redirect to the visitor's best-matching locale, and it is deliberately built to not fight the language switcher (see the switcher pattern below). If you already have a marketing-style root redirect, don't add both.
First use case: localized URLs. Wrap your application routes in the package's route group so every page gets a /en/..., /de/... prefix (with the default locale optionally unprefixed):
Route::l10n(function () {
Route::get('/', HomeController::class)->name('home');
Route::get('/pricing', PricingController::class)->name('pricing');
});
Then generate links with the package's URL helper instead of the plain route() helper, and you get a URL in the current locale automatically. Verify with php artisan route:list that you now see one entry per supported locale, then hit /de/pricing and confirm app()->getLocale() reports de inside the controller.
Wrap the package, don't sprinkle it. l10n has a small surface area and few dependents — but v0.5.3 is evidence that the surface moves. Create a thin LocaleService (or a Blade component plus a Locale facade of your own) that exposes current(), supported(), preferred(), switchUrl($locale), and isRtl(). When the package's resolver or event shapes shift again, you change one class rather than 40 call sites.
Language switcher. A controller action that reads the target locale from the route, validates it against the supported list, records it as a preference, then redirects back to the same page in the new locale. The redirect target is the tricky part — use the package's helper to translate the current route name + parameters into the target locale rather than redirecting to /.
public function switch(string $locale)
{
abort_unless(in_array($locale, config('l10n.locales')), 404);
$previous = config('app.preferred_locales', []);
// Namespace per the package source; fires with list payloads since 0.5.3.
event(new PreferredLocaleUpdated(
locales: [$locale, ...array_values(array_diff($previous, [$locale]))],
previousLocales: $previous,
));
return redirect()->to(l10n()->routeForLocale($locale, url()->current()));
}
Note the shape change: PreferredLocaleUpdated now exposes $locales and $previousLocales arrays instead of $locale / $previousLocale. Any listener that read the scalar properties must be updated — this is the most likely thing to break on upgrade.
Persistence: move off SessionLocale. SessionLocale is deprecated in this release. If you were relying on it to remember a manual switch, register a custom LocaleResolver in a service provider that reads your own session/cookie key and falls back to the package's resolvers. This is now the sanctioned path, and it also gets you out of the "cookies-disabled users silently reset" behaviour.
Preferred-locale matching is now list-aware. getPreferredLocales() returns the ranked list, and getPreferredLocale($locales) accepts an optional subset to match against. Use the latter when you want best-fit against a per-section subset (e.g. only locales a given product is translated into) rather than the global list:
$match = getPreferredLocale($product->availableLocales());
Validation and flash messages just work once the middleware runs before SubstituteBindings and validation — Laravel's __() calls in form requests and controllers pick up the locale the middleware set. No extra wiring needed. If you use queued jobs or mailables, capture the locale at dispatch time and restore it with app()->setLocale() inside the job, since the middleware never runs there.
Frontend / Inertia route helpers. If you use Laravel Wayfinder, v0.5.3 exports the LocalizedRoutes type and fixes argument forwarding (query options, required/optional args) in the generated TypeScript signature. Import LocalizedRoutes in your typed wrappers and composables instead of hand-rolling param types; it keeps the locale prefix and route params honest across the boundary.
Content that isn't in lang/ files. For database-backed content (products, pages), l10n won't help — it resolves locale, not translated columns. Pair it with a translatable model package and use app()->getLocale() as the key.
Testing. Set the locale explicitly in test setup ($this->app->setLocale('de')) or send an Accept-Language header and hit the localized URL. Add a test for the switcher that asserts the same path comes back with a different prefix, and a test that RedirectToPreferredLocale does not bounce a user who has just switched (its once-per-session guard should cover this — assert it rather than assume). Write at least one test per locale asserting a known translation string renders, so a missing lang/de/validation.php fails CI rather than production.
SEO. Emit <link rel="alternate" hreflang="..."> tags for every supported locale in your layout head, and make sure the canonical URL points at the locale-specific URL. This is the single most common thing teams forget when adding localized routing.
Middleware ordering is the #1 source of bugs. If SetLocale runs after route model binding or validation, you'll get errors in the wrong language. Append it at the front of the web group, not the end. RedirectToPreferredLocale, if used, goes immediately after it.
RedirectToPreferredLocale and your switcher. It is designed to redirect only once per session so that switching sticks. If you switch by writing to a custom key that the package's resolver doesn't read, the redirect may fire again and undo the user's choice. Point your custom LocaleResolver at the same preference the middleware consults, and exclude the switcher route from the redirect in config if it isn't already.
Custom resolvers must handle lists. LocaleResolver::resolve() may now return a list of locales — BrowserLocale returns every Accept-Language entry in preference order. A custom resolver that returns a plain string still works in the common path, but code that assumes a string (or a single-element array) will misbehave once browser detection returns two or three candidates. Normalise to an array at your boundary.
Breaking changes in a patch release. 0.5.3 renames config('app.preferred_locale') → config('app.preferred_locales'), changes the PreferredLocaleUpdated payload from scalars to arrays, and deprecates SessionLocale. Pin accordingly — "goodcat/laravel-l10n": "~0.5.3" rather than ^0.5 if you can't absorb a 0.6 surprise mid-sprint — and read UPGRADING/release notes before each bump. Pre-1.0 packages treat minor versions as major.
Route and config caching. php artisan route:cache and config:cache both freeze locale configuration — including the new preferred_locales key. If you add a locale in production, you must re-run both, otherwise the new prefix 404s. Put this in your deploy script.
Default locale URL inconsistency. Most opinionated localization packages hide the default locale's prefix (/pricing vs /de/pricing). This means url()->current() in the default locale has no prefix, which breaks naive switchers. Always derive switcher URLs from the route name, never from string manipulation of the current URL.
Don't trust the "detect from browser" path for a first visit. Accept-Language on corporate proxies and VPNs is frequently wrong. The list-aware matching helps, but it can't fix a wrong header. Prefer an explicit preference, fall back to browser detection once, then always honour the stored preference — which is exactly the flow RedirectToPreferredLocale implements, so lean on it rather than reimplementing.
Stateless flows. Session- and config-backed preferences are meaningless for API clients. For those, drive locale from a route parameter or Accept-Language explicitly, and remember the resolver now hands you the full ranked list — pick the first supported match yourself.
Small package, low bus factor. Still tiny in stars and dependents, single maintainer, MIT. That's not disqualifying — the scope is genuinely small and you can fork — but pin the version, read the source (a few files), and keep the wrapper described above so a future replacement is a one-file change. The churn in 0.5.3 is a good argument for the wrapper, not against the package.
Extension points. The LocaleResolver binding is the main one, and this release makes it more central: with SessionLocale deprecated, a custom resolver is the supported way to manage session preferences. The route group macro remains configurable for prefix style — check the config before writing your own routing wrapper on top. Watch the PreferredLocaleUpdated event too; it's the clean hook for analytics or cache-busting when a user changes language.
Debugging tip: when a page renders in the wrong language, log app()->getLocale() and config('app.preferred_locales') at three points — after the middleware, in the controller, and in the Blade layout. The locale is almost always being reset by something between those (a sub-request, a job, a redirect from RedirectToPreferredLocale, or a cached view fragment).
How can I help you explore Laravel packages today?