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.
Architecture fit
This package solves one narrow problem — locale-aware URL generation and canonicalization for Laravel's router — and it solves it in the least invasive way available: Route::localize() registers each route twice as static routes (without_locale.about and with_locale.about), then swaps Laravel's UrlGenerator so existing route('about') calls resolve to the locale-correct variant. That is the decisive architectural difference from mcamara/laravel-localization, which resolves locale dynamically at dispatch and is historically hostile to route:cache. For any app that deploys behind a cached route table (i.e. essentially every production Laravel app), this is the fit we want: no custom dispatcher, no dynamic route mutation, no per-request route registration.
The secondary surface is middleware: SetLocale resolves locale via URL prefix → session → cookie → detector chain → fallback, and RedirectLocale enforces the canonical URL form for that locale. Both skip routes not registered through the macros (via a locale_type action attribute), so /admin, /api/health, and webhook endpoints can coexist in the same web group without spurious redirects. That mixed-route safety valve materially reduces blast radius and is the detail that makes an incremental rollout feasible.
Fit is strongest for: public, marketing-adjacent, SEO-sensitive Laravel apps (content sites, SaaS marketing + app shells, docs portals) that want prefixed routing and translated slugs. Fit is weak for: API-only services (no benefit), SPA front ends that own routing client-side (the package's docs themselves flag the Inertia switcher path as experimental and the SPA guide as removed), and apps with heavy client-side route consumption via spatie/laravel-typescript-transformer, which the changelog explicitly marks incompatible because the generator indexes routes by controller rather than name and the dual-registered variants overwrite each other.
Integration feasibility
Low technical friction. One composer require, one config file, two middleware registrations, and a Route::localize() wrapper around the route groups we want localized. PHP 8.2–8.4 × Laravel 9–13 is a published, CI-tested support matrix that almost certainly covers our stack; Laravel 13 support already landed in 1.0.1, which is a decent signal on maintenance responsiveness. The package is MIT, ships no migrations, no service-provider magic beyond config and container bindings, and has zero transitive dependency weight (Ziggy adapters are suggest-only).
The migration story is unusually clean: mcamara/laravel-localization is marked abandoned in Composer with this package declared as the replacement, and a dedicated migration guide exists. That matters because it converts a "rip and replace" into a documented, upstream-endorsed path — relevant if we already have localizing code or if we inherit a project using the original.
The real integration cost is not in the package; it is in the surrounding surfaces. URL structure changes propagate to: sitemaps, hreflang alternate tags (the package gives us Route::localizedUrl() specifically for this), canonical tags, analytics/attribution (locale-prefixed paths fragment existing reporting), search console properties, marketing links and QR codes, and any external partner with hard-coded URLs. Budget integration effort accordingly: 1–2 dev-days for the technical pilot, 5–10 dev-days for a full rollout including SEO plumbing and per-locale QA, most of which is not package work.
Technical risk
UrlGenerator subclass — so forking is genuinely viable, and we should treat fork-ability as the continuity plan rather than assume upstream support.^1.4 and we should watch for 2.x rather than float.{locale} from the parameter bag) and explicitly told adopters to drop a $locale controller argument they may have added as a workaround. 1.4.0 fixed unnamed routes inheriting a bogus group name. These are the right fixes, but they confirm the surface is still settling in edge cases. Any code review of our controllers must check for positional-argument assumptions.Route::translate() bakes the without_locale.* variant at boot, so translated URIs cannot depend on per-request state. This is documented as a multitenancy caveat and is a real design ceiling if we ever want tenant-specific slugs.Accept-Language, session, and cookie, every shared cache (CDN, Varnish, full-page cache) needs a Vary strategy or a locale-in-path cache key. The redirect path makes this worse: a cached 302 keyed without Vary: Accept-Language will pin users to the wrong locale. This is the single most likely production incident, and it comes from the feature, not a bug.hreflang output is deterministic and that the default locale is reachable without a redirect hop.Key questions
/de/ueber) or only prefixed paths (/de/about)? This decides translate() vs localize() and changes SEO complexity substantially.hide_default_locale) or always prefixed? This must be decided before the first deploy because it changes every URL.Vary policy, and who owns it?mcamara integration to migrate, or is this greenfield?setActiveLocales() / setActiveDefaultLocale() request-scoped property handling is correct by design, but it needs a test.Stack fit Requires Laravel 9–13, PHP 8.2–8.4 — a narrow, current band. If any service is on PHP 8.1 or Laravel 8, this is a non-starter until that service is upgraded (which we may want anyway, but it becomes a hard dependency). No database, queue, cache, or storage coupling; nothing to provision.
Migration path Two entry points, both low-friction:
Route::localize(), register SetLocale and RedirectLocale in the web group, publish localizer.php, define supported locales. Leave admin/API routes outside the macro — mixed-route support means no collateral redirects.mcamara/laravel-localization: follow the dedicated migration guide. The package is the Composer-declared replacement, so composer require cleanly supersedes the abandoned dependency. Expect to re-point config keys (laravelLocalization.* → localizer.*) and reconcile helper names (getLocalizedURL → Route::localizedUrl). This is a rename-and-verify exercise, not a re-architecture, but it touches every view that builds locale links.Compatibility
route:cache — explicitly supported; this is the package's headline claim and should be our first acceptance test.spatie/laravel-typescript-transformer — incompatible; do not plan around it.Sequencing
route:cache boots, verify route('about') resolves correctly per locale, verify /admin and a POST endpoint produce no redirects.hreflang emission via Route::localizedUrl(), sitemap generation, redirect map (301) for any existing URLs. Freeze this before code review.Vary handling and confirm CDN configuration in staging — this is a prerequisite to any traffic, not a follow-up./de only), on public routes, with analytics segmented by path prefix.Route::localizedSwitcherUrl() for in-page switchers — the README is explicit that a plain /about link carries no locale signal and RedirectLocale will bounce the user back to their session locale. This is the highest-probability UX bug and deserves a test.->locale(), HasLocalePreference, queued jobs via Localizable::withLocale()).setActiveLocales() narrows the runtime locale set without breaking route:cache.Maintenance
Near-zero infrastructure maintenance: no services, no schema, no background work. The ongoing burden is upgrade maintenance on a ^1.4 pin and periodic review of upstream changelogs, which have historically contained behaviour changes worth reading (controller argument semantics, unnamed-route naming). Assign one owner for the pin and for reading release notes — that is genuinely the whole cost. Budget a small amount for fork-readiness: a documented, tested fallback of "vendor the package into app/" if upstream goes quiet.
Support
Community-only: GitHub issues, no SLA, no vendor to escalate to. Acceptable for a routing utility (failure is loud and immediate, not silent), less acceptable if we were relying on it for anything security-adjacent — which we are not. Internal support model: one engineer who has read docs/caveats-and-recipes.md and can triage redirect issues. The docs are above average for a 40-star package (grouped README, migration guide, multitenancy guide, caveats doc, JS helpers doc, language-switcher doc), so ramp-up cost is low but not zero.
Scaling
Route table grows roughly linearly with locales: N routes × L locales for localize(), plus per-locale URI permutations for translate(). At 200 routes × 10 locales that is ~2,000 static entries — fine for route:cache, but the cached file grows and cold boot memory with it. Two controls: (a) localize only the groups that need it — do not blanket the app; (b) for multitenant or long-tail locale scenarios, Localizer::setActiveLocales() narrows the runtime set while keeping the boot-time union static, which preserves cacheability. Runtime dispatch cost per request is negligible — one middleware chain pass and a UrlGenerator override.
Failure modes (ranked by likelihood × impact)
/about (or caches the 302) because Vary is absent. Symptom: users report "the site is in the wrong language," intermittent by edge node. Highest risk; mitigate in staging with explicit Vary/cache-key tests before launch.localizedSwitcherUrl(), silently return users to their session locale. Symptom: "the language switcher doesn't work." Documented, common, easily tested.hide_default_locale combined with a proxy that rewrites the prefix. Symptom: ERR_TOO_MANY_REDIRECTS on one market. Cheap to detect with a smoke test per locale in CI.RedirectLocale intentionally skips POST/PUT/PATCH/DELETE to avoid the 302→GET body-loss downgrade — correct design, but it means a POST to the unprefixed path resolves locale from session/cookie only. Acceptable; just ensure form actions are generated via route() so they carry the prefix.Route::translate() now throws UnnamedTranslatedRouteException at registration. This is a good failure (fails fast with the offending URI named) but it converts a latent runtime bug into a deploy-time one — expect it the first time a developer adds an unnamed route inside a translate() group, and put it in the PR checklist.{locale} is stripped from the parameter bag, so controllers receive the first real URI parameter. Any controller written against the old mcamara assumption will break visibly on first request — good, but only after deploy. Covered by the spike if the spike localizes a parameterized route.Ramp-up
Small and well-bounded. A Laravel developer unfamiliar with the package needs roughly half a day to internalize the model (dual registration, locale resolution chain, when to use route() vs localizedUrl() vs localizedSwitcherUrl()), plus the caveats doc. QA needs a per-locale matrix: first visit with Accept-Language, returning visit with session, explicit prefix authority, switcher behavior, POST behavior, and crawler behavior on the unprefixed URL. That matrix is the real ramp-up artifact — build it once and reuse it as a CI smoke suite.
Recommendation
Adopt with guardrails for the public, SEO-sensitive route surface; keep admin/API out of scope initially. The architectural bet — static dual-registered routes, route:cache-safe, minimal middleware — is the right one and materially better than the abandoned incumbent it replaces. The risks are adoption-base thinness (mitigated by MIT + fork-ability + thin surface) and cache/SEO correctness (mitigated by sequencing and staging verification). Treat this as a low-cost, fork-tolerant utility dependency, not a platform commitment, and gate production traffic on the cache policy being settled first.
How can I help you explore Laravel packages today?