DEV Community

Cover image for Localized routes in Laravel with Laralang
Eduardo Lázaro
Eduardo Lázaro

Posted on

Localized routes in Laravel with Laralang

Translating the text in a Laravel app is one problem, and allowing localized URLs is a different one. You want /dashboard in English and /es/panel in Spanish, both real routes that resolve, appear in route:list, and can be linked without knowing which language the visitor is on. Doing it by hand means a route group per language, duplicated definitions, and locale detection glued on top.

Laralang does that half. You declare a route once, list the languages, and it registers one real route per locale, names them, and keeps route() working. Here is the whole thing, including the parts that are easy to get wrong.

Install

composer require edulazaro/laralang
php artisan vendor:publish --tag="locales"
Enter fullscreen mode Exit fullscreen mode

Publishing is not optional in practice. The config ships with a single locale, and both the middleware and the URL generator read the list from there, so until you fill it in your other languages are not recognised.

// config/locales.php
'locales' => ['en', 'es', 'fr'],

'prefixes' => [
    'en' => '',    // default language, no prefix
    'es' => 'es',  // /es/...
    'fr' => 'fr',  // /fr/...
],
Enter fullscreen mode Exit fullscreen mode

Two rules about prefixes that save a debugging session.

The unprefixed language is config('app.locale'), not the first item in the array. If your app runs in Spanish, APP_LOCALE=es is what makes /contacto live at the root and /en/contact carry a prefix.

A value is the literal prefix, and without one the locale code is used. A missing key, null and an empty string all count as no value, so only the default language can end up at the root and two locales can never fight over the same URLs.

Declare a localized route

Swap Route::get for LocalizedRoute::get. The second argument is the locale list. A bare locale reuses the base path, and 'locale' => 'translated-path' gives that language its own URL.

use EduLazaro\Laralang\LocalizedRoute;

LocalizedRoute::get('dashboard', [
    'en',
    'es' => 'panel',
], [DashboardController::class, 'index'])->name('dashboard');
Enter fullscreen mode Exit fullscreen mode

That registers /dashboard and /es/panel. Everything you chain applies to every generated route, so middleware, where constraints and the rest work as usual. There is a method per verb (get, post, put, patch, delete, options, any) plus match.

Resources work too, and this is where a package like this usually gives up:

use EduLazaro\Laralang\LocalizedResource;

LocalizedResource::make('photos', [
    'en',
    'es' => ['uri' => 'fotos', 'verbs' => ['create' => 'crear', 'edit' => 'editar']],
], PhotoController::class)->only(['index', 'show']);
Enter fullscreen mode Exit fullscreen mode

Laravel's own resource registrar builds the routes, so only(), except(), names(), shallow() and scoped() keep working and apply to every language at once. You get /photos/create and /es/fotos/crear, and both are named photos.create under their locale.

Route names carry the locale

Every generated route is named {locale}.{name}, and there is no route registered under the bare name. Be explicit when you need a specific language:

route('es.dashboard');   // /es/panel
route('en.dashboard');   // /dashboard
Enter fullscreen mode Exit fullscreen mode

And use the plain name everywhere else:

route('dashboard');      // whatever the current locale is
Enter fullscreen mode Exit fullscreen mode

That works because Laralang replaces Laravel's URL generator. route('dashboard') looks for es.dashboard when the app locale is Spanish, and falls back to a route literally named dashboard if there is no localized one, which is what lets you mix Route::get and LocalizedRoute::get in the same app without thinking about it.

Active states in navigation keep working too, because routeIs() understands the naming:

request()->routeIs('dashboard');      // true on en.dashboard and on es.dashboard
request()->routeIs('es.dashboard');   // true only when you are on the Spanish one
Enter fullscreen mode Exit fullscreen mode

Translated slugs, not just translated paths

This is where it stops being cosmetic. Route model binding accepts a different column per locale, so each language resolves its own slug:

LocalizedRoute::get('property/{property:slug_en}', [
    'en' => 'property/{property:slug_en}',
    'es' => 'propiedad/{property:slug_es}',
    'fr' => 'propriete/{property:slug_fr}',
], [PropertyController::class, 'show'])->name('properties.show');
Enter fullscreen mode Exit fullscreen mode

/propiedad/piso-en-el-centro and /en/property/flat-in-the-centre hit the same controller with the same model, resolved through a different column. And because route() picks the localized route for the current locale, route('properties.show', $property) builds the right URL with the right slug without any branching in your views.

A package that registers a single route with a dynamic locale prefix cannot express this: there is one definition, so there is one binding field.

Every URL of the current page, in one call

Language switchers, hreflang tags and multi locale sitemaps are all the same question: what is this page called in the other languages?

@foreach(Laralang::alternates() as $locale => $url)
    <link rel="alternate" hreflang="{{ $locale }}" href="{{ $url }}" />
@endforeach
Enter fullscreen mode Exit fullscreen mode

alternates() carries the current route parameters over, so dynamic segments survive, and it only returns the locales that actually have a route registered. A page that exists in two of your three languages produces two links instead of a dead one.

Groups and prefixes

The locale prefix always goes first, even inside a prefixed group:

Route::prefix('admin')->group(function () {
    LocalizedRoute::get('dashboard', [
        'en',
        'es' => 'tablero',
    ], [AdminController::class, 'index'])->name('admin.dashboard');
});

// /admin/dashboard
// /es/admin/tablero
Enter fullscreen mode Exit fullscreen mode

Detecting the locale

Every route created by LocalizedRoute already carries the SetRouteLocale middleware, so the localized routes need no wiring. The middlewares matter for everything else: plain routes, and any part of the app with no locale in the path.

  • SetRouteLocale reads the locale from the route name or the URL prefix, and stores it in the session.
  • SetSessionLocale applies whatever the session holds. This is the one for an admin panel where the language is a user setting, not part of the URL.
  • SetBrowserLocale picks the best match from Accept-Language on first visit.
  • SetSmartLocale combines them: prefix when there is one, session or browser when there is not.

They are idempotent, so stacking them is safe.

Two behaviours worth knowing before they surprise you.

The default locale prefix is stripped with a 301. A request to /en/contact when English is the default redirects to /contact, so each page has one canonical URL.

Livewire and AJAX requests take the session path. Those hit an endpoint with no locale prefix, so SetRouteLocale delegates to the session, which the previous full page load already wrote. If a Livewire component ever renders in the wrong language while the page around it is fine, that session value is the thing to inspect.

Rescuing URLs before they 404

A URL gets shared without its prefix, or with the wrong one. /servicios reaches an app running in English, /es/services reaches one that expects the Spanish path. Both are a 404 by default, and both are a page you already have.

// config/locales.php
'fallback' => true,
Enter fullscreen mode Exit fullscreen mode
Requested Rescued to
/servicios /es/servicios
/fr/servicios /es/servicios
/es/services /services

It only runs once the router has already failed, so normal traffic costs nothing, and the candidate URL is handed to the router rather than compared as a string, so routes with parameters are rescued too. Locales are tried with the default one first, so the destination depends only on the path and never on the visitor, which is what makes a permanent redirect safe here.

If your app has its own fallback route, leave the config off and call LocalizedRoute::fallback() yourself before it: Laravel runs the first fallback registered.

Prefixing every language

By default the main language lives at the root. Give it a prefix of its own and that stops:

'prefixes' => [
    'en' => 'en',   // /en/about
    'es' => 'es',   // /es/sobre-nosotros
],
Enter fullscreen mode Exit fullscreen mode

Nothing is registered at / any more, so turn the fallback on and the root sends visitors to their own language, taken from their stored choice and then from Accept-Language. That one is a 302 with Vary: Accept-Language, never a 301, because the destination depends on who is asking and a permanent redirect would be cached and pin a single language for everyone behind it.

Decide this before going live. Permanent redirects get cached by browsers and CDNs and are never re-checked, so changing the shape of your URLs later leaves returning visitors following redirects to pages that no longer exist. The package bounds that window with an explicit lifetime you control:

'redirect_max_age' => 86400,
Enter fullscreen mode Exit fullscreen mode

Signed URLs that survive translation

A signed URL is built from the full URL, so a link generated in Spanish and validated in a request that switched to English fails the signature check. There is a signed_localized middleware for exactly that: it reads the locale from the route name, sets it, and then runs Laravel's own validation.

LocalizedRoute::get('/verify-email/{id}/{hash}', [
    'en' => '/verify-email/{id}/{hash}',
    'es' => '/verificar-email/{id}/{hash}',
], [VerifyEmailController::class, 'verify'])
    ->middleware(['signed_localized'])
    ->name('verification.verify');
Enter fullscreen mode Exit fullscreen mode

Ziggy

If Ziggy 2 is installed, Laralang swaps its Blade route generator so the routes of the current locale are also published under their unprefixed name. route('dashboard') in JavaScript then behaves like it does in PHP, and a plain route already using that name is never overwritten.

Wrapping up

That is localized routing done: one declaration per page, translated paths and translated slugs, resources included, route() that follows the current language, hreflang in one call, and a fallback that rescues the URLs you would otherwise lose.

It runs on PHP 8.2 to 8.5 and Laravel 11, 12 and 13, with every combination tested on each push.

👉 Package on Packagist: https://packagist.org/packages/edulazaro/laralang
👉 Source is on GitHub: https://github.com/edulazaro/laralang

If you are coming from another localization package, the repo has a migration guide for mcamara/laravel-localization and another for niels-numbers/laravel-localizer.

Top comments (0)