codedor/laravel-translatable-routes
codedor/laravel-translatable-routes adds multilingual, SEO-friendly URLs to Laravel. Define translated route segments per locale, generate locale-aware links, and switch languages without losing the current page. Ideal for localized apps that need clean, human-readable URLs in every language.
wotzebra/laravel-translatable-routes lets you declare a route once and have its URI segments (and therefore its generated URLs) differ per locale — /en/about vs /fr/a-propos — while keeping a single stable route name so route('about') keeps working everywhere.
Minimum steps:
composer require wotzebra/laravel-translatable-routesphp artisan vendor:publish --tag="translatable-routes-config"
config/app.php / the package config, and that your SetLocale middleware (or App::setLocale()) runs before routing resolves the URL — that is the single most common first-run failure.lang/{locale}/routes.php), keyed by a slug identifier rather than by the English text.Route::translatable(function () {
Route::get('/', [HomeController::class, 'index'])->name('home');
Route::get('/about', [PageController::class, 'about'])->name('about');
});
php artisan route:list # should show one entry per locale, same name
php artisan tinker --execute="app()->setLocale('fr'); echo route('about');"
First use case to get working end-to-end: home + about + contact pages in two locales, plus a working language switcher that redirects to the same page in the other locale. If the switcher works, your middleware ordering, route naming and URL generation are all correct — everything else in this package is a variation on that.
Where to look first: the README's route-group example, then the config file (locale list + fallback behaviour), then route:list. Do not start by reading the source — the value of this package is the registration API, and route:list tells you faster than code whether it did what you expected.
One route name, many URLs. The whole point is that route('about') is locale-agnostic. Resist creating about.en / about.fr names — you lose the abstraction and every route() call becomes a match. Keep ->name('about') singular and let the package resolve the segment.
Route group per locale with a prefix, or a single translated group. Two viable shapes:
// Shape A: locale prefix in the URL, one group
Route::group(['prefix' => '{locale}'], function () {
Route::translatable(function () {
Route::get('/about', ...)->name('about');
});
});
// Shape B: each locale is its own host/prefix, registered in a loop
foreach (config('app.supported_locales') as $locale) {
Route::group(['prefix' => $locale, 'middleware' => "locale:$locale"], function () {
Route::translatable(fn () => require base_path('routes/web.php'));
});
}
Shape B is easier to reason about for SEO (every locale is crawlable, no locale negotiation), Shape A is less duplication. Pick one early — mixing them mid-project is painful.
Language switcher. The canonical implementation is: take the current route name and parameters, then regenerate in the target locale.
public function switch(string $locale)
{
abort_unless(in_array($locale, config('app.supported_locales')), 404);
$route = request()->route();
$params = $route->parameters(); // keep model bindings intact
session(['locale' => $locale]);
return redirect()->to(
route($route->getName(), $params + ['locale' => $locale], absolute: true)
);
}
Passing the bound model (not the slug string) is important: a translated slug for a Post will not resolve back to the model if you pass the raw {post} string into a differently-localized route.
Localized slugs with route model binding. If you translate the slug of a resource route, resolve models by a translated column rather than id:
Route::get('/articles/{post:slug_'.app()->getLocale().'}', ...);
This couples routing to schema. The cleaner pattern is a slug table keyed by (model, locale) and a resolveRouteBinding() override that queries it — the package handles the inbound match, your model handles the lookup.
Locale persistence middleware. Order matters: SetLocale (session → App::setLocale) must run before SubstituteBindings, and the package's own resolution must see the final locale. If URLs are locale-prefixed, derive locale from the prefix and ignore session for that request — otherwise a French session on an English URL produces inconsistent route() output inside the same response.
hreflang and sitemaps. The package makes this trivial and it is a strong reason to adopt it. In your layout:
@foreach (config('app.supported_locales') as $locale)
<link rel="alternate" hreflang="{{ $locale }}"
href="{{ route($routeName, $params + ['locale' => $locale]) }}">
@endforeach
Same loop builds sitemap entries — no hand-maintained URL lists that drift when someone edits a translation file.
Emails, jobs and queued notifications. Never rely on ambient locale. Wrap generation explicitly:
$url = app()->setLocale($user->locale) && false ?: route('about');
// or, cleaner:
$url = trans_url('about', [], $user->locale);
Whatever the package's explicit-locale helper is (check the README — it exposes one for exactly this), use it. Queue workers have no request locale; a URL generated in a job will silently be the default locale.
Testing. Assert per-locale URLs directly and assert cross-locale reachability:
it('serves translated urls', function () {
$this->get('/fr/a-propos')->assertOk();
expect(route('about', [], false))->toBe('/fr/a-propos');
});
Add one test that every named route resolves in every configured locale — it catches missing translation keys at CI time instead of in production.
PHP 8.5 / Laravel 13 (v2.1.0). This release is compatibility-only: no API changes, no deprecations. Two practical implications: (a) you can bump the constraint without touching code, and (b) if you are not yet on Laravel 13 this buys you nothing — pin ^2.0 and move on. The other two changes are Dependabot bumps to CI actions, irrelevant at runtime. Nothing here changes your implementation, but check whether your other route-touching packages (localization, sitemap, SEO) also support Laravel 13 before upgrading the framework itself.
route:cache will bite you. Translated routes are still routes. Any change to a lang file changes the compiled route table — deploy must run php artisan route:clear (or a fresh route:cache) or your new French slug simply won't exist. Put it in your deploy script; this is the number-one "it works locally" bug with this package.
Duplicate route names are the classic failure. If you register ->name('about') inside a per-locale loop and the package also registers one per locale, you get N entries with the same name. route() returns the last-registered one, so URLs silently come out in the wrong locale. Sanity check: php artisan route:list --name=about should show exactly one row per locale, no more.
route() output is locale-of-the-moment, not locale-of-the-URL. Inside a request to /fr/... they agree, only because your middleware set the locale. In a Blade partial rendered from a cache, an API response, or a queued job they will not. Treat ambient locale as a rendering concern, never as a data concern.
Missing translation keys fail quietly. Depending on config, an untranslated segment falls back to the base locale or to the raw key. Either is worse than a loud failure in dev — set the fallback behaviour deliberately and add the "all routes resolve in all locales" test above.
Signed URLs and localized paths don't mix well. A temporary signed URL built in one locale will 403 if the user's session/locale causes the signature to be validated against a different path. Generate and validate signed routes under an explicit locale, or keep signed routes outside the translatable group.
Non-ASCII segments. Translating to /fr/à-propos produces percent-encoded URLs that look wrong in logs and can mismatch in tests. Prefer ASCII-only translated slugs, and assert with rawurldecode() when it's unavoidable.
Config and lang caching. php artisan config:cache freezes the locale list; lang files are read at runtime. If you add a locale in .env but don't re-cache, the route disappears. Same deploy-script discipline as route:cache.
Extension point worth knowing. If the package's segment resolution doesn't cover a case (dynamic translated slugs, translated query strings), it is almost always cleaner to wrap route() in a small helper of your own than to subclass the router. Keep the customization in one helper so a future package upgrade stays a one-file diff.
How can I help you explore Laravel packages today?