Skip to content

Migrating from mcamara/laravel-localization

This guide walks through swapping mcamara/laravel-localization for niels-numbers/laravel-localizer on an existing app. The two packages solve the same problem - locale-prefixed routes plus auto-detection - but the wiring differs.

Why migrate

The original package was the first to tackle locale-aware routing in Laravel and powered multilingual apps for a decade. Several issues that piled up over time were not fixable as patches - they were consequences of generating routes dynamically per request. This rewrite addresses them at the architecture level:

  • php artisan route:cache is now working. For the original, route:cache did not work - routes were generated dynamically per request, so the cache either silently broke the app or shipped a cache for one locale only. The original shipped its own route:trans:cache command as a custom workaround, which itself broke on Laravel 11 (mcamara/laravel-localization#927). This package registers two static routes per definition (one with a {locale} placeholder, one without), so plain route:cache works on every supported Laravel version.

  • Higher compatibility with the wider Laravel ecosystem. Because routes are static instead of dynamically generated per request, third-party packages that introspect or cache the route table - Ziggy, Wayfinder, Telescope, route-list-driven tooling - see every variant up front and don't get surprised. Many of the collisions reported against the original package were downstream consequences of the dynamic-routing model; switching to static routes removes the root cause rather than patching the symptoms.

  • php artisan route:list shows every variant. In the original, route:list only listed the routes registered for the current process's locale - a misleading partial view that hid half your app's URLs. Here, with_locale.{name} and without_locale.{name} (and per-locale translated_{locale}.{name} for Route::translate()) appear as separate rows in one table.

  • No more locale leakage onto unlocalized routes. The original's recommended 'prefix' => LaravelLocalization::setLocale() had a side effect: hitting any route - even one in urlsIgnored - would call App::setLocale(). That broke /admin, /api/health, and anything else outside the localized group. Here, both SetLocale and RedirectLocale only act on routes registered through Route::localize() / Route::translate() (detected via the locale_type action attribute the macros set); plain routes in the same web group pass through with zero side effects.

  • POST/PUT/DELETE redirects no longer lose form bodies.RedirectLocale skips non-safe methods to avoid the 302→GET browser downgrade that silently dropped request bodies in the original.

  • Translated routes with placeholders just work - including optional {type?} segments and dynamic model slugs. The original package built localized URLs by reverse-engineering the current request URI back to its translation key (getRouteNameFromAPath), then re-translating into the target locale. That reverse-lookup ran the URI through parse_url(), which treats ? as the start of a query string and mangled optional segments - services/{type?} became services/{type (mcamara/laravel-localization#933). Translated routes with dynamic slugs produced 404s on locale switch for the same family of reasons (#885). This package sidesteps the whole class of bugs by registering one named static route per locale up front:

    translated_en.service.detail   →   /services/{type?}
    translated_de.service.detail   →   /de/sluzby/{type?}

    Switching to German with Route::localizedUrl('de') is then a name lookup, not a URI rewrite - it resolves translated_de.service.detail and hands the current parameters (['type' => 'cloud']) to Laravel's own URL generator, which fills the placeholder natively. No parse_url, no string surgery on the URI, no reverse-lookup from path to lang key. The original lang key is consumed once at registration time (in Route::translate()) and never reconstructed from a request URI again, so there is no equivalent of getRouteNameFromAPath in this package - the question it answered is simply never asked.

  • Five middlewares collapsed into two. localize, localizationRedirect, localeSessionRedirect, localeCookieRedirect, localeViewPath had subtle ordering rules and silently broke locale persistence if you got them wrong. Here: SetLocale + RedirectLocale, with persistence as config flags (persist_locale.session / persist_locale.cookie).

  • First-class adapters for Ziggy and Wayfinder. The original had no client-side story; Inertia setups had to roll their own.

1. Swap the package

bash
composer remove mcamara/laravel-localization
composer require niels-numbers/laravel-localizer

The service provider auto-registers via package discovery.

2. Replace the middleware

The old package shipped five middleware aliases that you composed per route group; this package collapses the same surface into two. Drop all of these:

Old aliasOld classReplaced by
localizeLaravelLocalizationRoutesSetLocale (URL → locale resolution)
localizationRedirectLaravelLocalizationRedirectFilterRedirectLocale (canonical redirect)
localeSessionRedirectLocaleSessionRedirectpersist_locale.session config + SetLocale
localeCookieRedirectLocaleCookieRedirectpersist_locale.cookie config + SetLocale
localeViewPathLaravelLocalizationViewPathnot built in - see What's not migrated

Add the new package's two middleware to the web group:

php
// Laravel 11+: bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
    $middleware->web(append: [
        \NielsNumbers\LaravelLocalizer\Middleware\SetLocale::class,
        \NielsNumbers\LaravelLocalizer\Middleware\RedirectLocale::class,
    ]);
})

For Laravel 9/10, register both classes in the web group in app/Http/Kernel.php.

No more middleware-order footguns. In the old package, session and cookie persistence were each their own middleware that you chained on every localized group. Getting the order wrong - or attaching localizationRedirect without localeSessionRedirect, or vice versa - silently broke locale persistence or caused redirect loops. Here, persistence is configured (persist_locale.session / persist_locale.cookie) and handled inside SetLocale. The only ordering constraint is that SetLocale must run before SubstituteBindings, which the web group already guarantees.

3. Rewrite route definitions

Replace the prefix + middleware wrapper with Route::localize():

php
// Before
Route::prefix(LaravelLocalization::setLocale())
     ->middleware(['localizationRedirect', 'localeSessionRedirect', 'localizationRedirect', 'localeViewPath'])
     ->group(function () {
         Route::get('/about', AboutController::class)->name('about');
     });

// After
Route::localize(function () {
    Route::get('/about', AboutController::class)->name('about');
});

The macro registers two static routes per definition (one with the {locale} placeholder, one without) instead of one dynamic prefix that mutates per request. That's what makes route:cache safe - see step 6.

For translated URI paths (/de/ueber, /fr/a-propos):

php
// Before
Route::group(['prefix' => LaravelLocalization::setLocale()], function () {
    Route::get(LaravelLocalization::transRoute('routes.about'), AboutController::class)
         ->name('about');
});

// After
use NielsNumbers\LaravelLocalizer\Facades\Localizer;

Route::translate(function () {
    Route::get(Localizer::url('about'), AboutController::class)->name('about');
});

The translation file shape is unchanged - keep lang/{locale}/routes.php as it is.

4. Replace URL helpers with named routes

The supported path is named routes + route(). The URL generator picks the locale-aware variant automatically; you do not have to pass the locale explicitly.

php
// Before
LaravelLocalization::localizeUrl('/users');
LaravelLocalization::getURLFromRouteNameTranslated($locale, 'routes.users');
LaravelLocalization::getLocalizedURL($locale);   // current page in another locale

// After
route('users');                       // current locale
route('users', ['locale' => 'fr']);   // explicit override
Route::localizedUrl('fr');            // current page in another locale (canonical, for hreflang)
Route::localizedSwitcherUrl('fr');    // switcher link (always prefixed)

There is no direct replacement for localizeUrl('/users') (path-based lookup). If a route doesn't have a name yet, give it one and use route(). The two Route::localized…Url() helpers differ in whether they emit the prefix for the default locale; the Template Helpers page explains when to use which.

5. Migrate config

The old config/laravellocalization.php maps to the new config/localizer.php as follows:

OldNewNotes
supportedLocales (keys)supported_localesJust the codes: ['en', 'de', 'fr']. The old package's nested name / script / native arrays are not supported here - keep that data in your own list if your switcher renders it.
useAcceptLanguageHeaderimplicit via BrowserDetector in detectorsEnabled by default. Remove BrowserDetector::class from detectors to disable.
hideDefaultLocaleInURLhide_default_localeSame semantics.
localeSessionRedirect middlewarepersist_locale.sessionMoved from middleware to config (see step 2).
-persist_locale.cookieNew: cookie persistence. On by default.
localesOrder, localesMapping-Not supported. Use a custom detector if you need locale aliasing.
defaultLocaleconfig('app.fallback_locale') in config/app.phpThis package reads the framework's fallback locale; don't redefine it here.

Publish the new config and port your values:

bash
php artisan vendor:publish --provider="NielsNumbers\\LaravelLocalizer\\ServiceProvider" --tag=config
php
// config/localizer.php
return [
    'supported_locales'   => ['en', 'de', 'fr'],   // from old supportedLocales
    'hide_default_locale' => true,                 // from old hideDefaultLocaleInURL
    'persist_locale'      => [
        'session' => true,                         // had localeSessionRedirect? leave true
        'cookie'  => true,
    ],
    // 'detectors' default is fine for most apps
];

Then delete config/laravellocalization.php.

6. Enable route:cache (and drop route:trans:*)

The old package generated routes dynamically per request, so plain php artisan route:cache either silently broke the app or shipped a cache for one locale only. The package shipped its own commands as a workaround:

  • php artisan route:trans:cache - used in place of route:cache
  • php artisan route:trans:clear - used in place of route:clear
  • php artisan route:trans:list {locale} - route:list per locale

None of that is needed here. Use Laravel's built-in commands directly:

bash
php artisan route:cache
php artisan route:clear
php artisan route:list

The two physical routes per definition are static and deterministic. The locale-aware selection between them happens at runtime in the URL generator, which is unaffected by the route cache. Same for Route::translate() - those URIs are baked in at registration time.

Action items:

  • Replace route:trans:cache / route:trans:clear calls in deployment scripts, composer.json scripts, CI pipelines and Forge/Envoyer recipes with the plain route:cache / route:clear.

  • Remove the LoadsTranslatedCachedRoutes trait from app/Providers/RouteServiceProvider.php:

    php
    // Delete these two lines:
    // https://github.com/mcamara/laravel-localization/blob/master/CACHING.md
    use \Mcamara\LaravelLocalization\Traits\LoadsTranslatedCachedRoutes;

    The trait overrode loadCachedRoutes() to dispatch to a per-locale cache file. With the new package, Laravel's default cache loader is the right thing - no override needed.

  • php artisan route:list shows every variant in one table; both with_locale.about and without_locale.about (or the per-locale translated_de.about etc.) appear as separate rows. There is no per-locale filter - pipe through grep if you need one.

7. Update Ziggy / JS route helpers

If your app uses Ziggy (or Inertia with Ziggy underneath), the server-side variant selection that route() does in PHP does not happen in JS for free - Ziggy emits all with_locale.* / without_locale.* route names verbatim. Install the LocalizerZiggy adapter; it rewrites the manifest before it ships to the client.

See docs/javascript-route-helpers.md - the doc covers both Ziggy variants (tighten/ziggy v2+ and tightenco/ziggy v1, which need different bindings) and the Wayfinder helper.

What's not migrated

A few features of the old package have no built-in equivalent here. Most apps don't need them; if yours does, the workaround is usually straightforward.

  • localeViewPath middleware (per-locale Blade view directories). Not built in. Use Laravel's view namespaces or call View::addLocation(...) from a service provider keyed on App::getLocale().
  • LaravelLocalization::getCurrentLocale() and friends. Just use App::getLocale() / App::setLocale() directly - Laravel's own API.
  • Locale aliasing (localesMapping). Implement as a custom detector that maps the alias to a supported locale and register it in detectors. See Detectors.
  • Nested locale metadata (name, script, native, regional). Not part of this package's config. Keep that data in your own list if your switcher renders it.

Released under the MIT License.