Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Translatable Routes Laravel Package

wotz/laravel-translatable-routes

A Laravel package for translatable routes that lets you define localized URL segments and route translations, so links adapt to each supported language. Simplifies multilingual, SEO-friendly routing within Laravel’s existing route system.

View on GitHub
Deep Wiki
Context7

Getting Started

1. Install and verify discovery

composer require wotz/laravel-translatable-routes

The service provider should be auto-discovered. Pin the version in composer.json rather than floating — this is a small, single-maintainer package:

"wotz/laravel-translatable-routes": "^2.1"

Version note (v2.1.0): this release is a platform-support bump, not a feature release. It adds PHP 8.5 / Laravel 13 support and refreshes the CI toolchain (dependabot bumps for fetch-metadata and composer-install). There are no new APIs, no deprecations, and no breaking changes relative to 2.0. Two practical consequences:

  • If you are on Laravel 13 or PHP 8.5, you need 2.1.0 or later — 2.0 will not have been tested against them.
  • If you are still pinned to ^1.0, do not jump straight to ^2.1 blind: 2.0 was a major, and the 2.1 notes only describe 2.0 → 2.1. Read the 2.0 changelog first, then land on ^2.1.

Before writing any routes, confirm the public API surface by reading the README for the version you actually installed. The one thing worth checking first is the route-registration entry point: whether it exposes a Route::translatable(...) macro, a dedicated registrar class, or expects you to use a locale-prefixed group plus a slug resolver. Everything else follows from that.

2. Publish config (if offered)

php artisan vendor:publish --tag=translatable-routes-config

You are typically looking for three knobs: the list of supported locales, the default/fallback locale, and the translation namespace used for slugs.

3. Create the slug translation files

The usual contract is one file per locale mapping a stable key to a localized slug:

// lang/en/routes.php
return [
    'about'    => 'about-us',
    'pricing'  => 'pricing',
    'contact'  => 'contact',
];

// lang/fr/routes.php
return [
    'about'    => 'a-propos',
    'pricing'  => 'tarifs',
    'contact'  => 'contact',
];

Keys are your source of truth and should never change; slugs are free to change per locale.

4. Register your first route

Whatever the exact syntax, the mental model is: the route is identified by key, and the URI is resolved from the key + current locale at registration time.

Route::translatable(function () {
    Route::get('about',   [PageController::class, 'about'])->name('about');
    Route::get('pricing', [PageController::class, 'pricing'])->name('pricing');
});

5. Verify before building on top

php artisan route:list
php artisan route:list --path=fr   # if locale prefixes are configured

Then flip locale in tinker and confirm route('about') changes:

app()->setLocale('fr');
echo route('about'); // https://app.test/fr/a-propos

First real use case: a public marketing/content site where the URL slug must be localized for SEO (/en/about-us vs /fr/a-propos) while the controller, view, and route name stay identical. That is the whole value proposition — do not try to use this for an authenticated dashboard where URLs are internal and slugs are meaningless.


Implementation Patterns

Stable keys, translated slugs, single route name. Resist the temptation to name routes per locale (about.en, about.fr). One name + locale-aware route() generation keeps every route('about') call in your views, controllers, and mailables correct without conditionals. Locale is ambient state, not a route parameter you pass around.

Locale detection as middleware, not route logic. Put locale resolution in a middleware that runs before route resolution: session/cookie override → user preference → Accept-Language negotiation → default. The route layer should only ever consume app()->getLocale(). Keeping these separate means the package stays a URL-formatting concern and your locale policy lives in one testable class.

A locale switcher that preserves the current page.

public function switch(string $locale)
{
    abort_unless(in_array($locale, config('translatable-routes.locales')), 404);

    // Resolve the *named* route + current params, then regenerate in the target locale.
    $route = request()->route();

    app()->setLocale($locale);
    session(['locale' => $locale]);

    return redirect()->to(
        route($route->getName(), $route->parameters(), true)
    );
}

Resolve by name and parameters, never by rebuilding the path from request()->path() — that is the single most common source of broken switchers.

Canonical redirects for wrong-locale slugs. If /fr/about-us resolves, 301 it to /fr/a-propos. This prevents duplicate-content penalties and keeps the "one URL per page per locale" invariant. Implement it in the same middleware that handles locale: if the resolved locale's slug differs from the request path, redirect.

Sitemaps and hreflang. This package's main downstream consumer is SEO. Generate the sitemap by iterating locales × named routes:

foreach (config('translatable-routes.locales') as $locale) {
    app()->setLocale($locale);
    foreach ($publicRoutes as $name) {
        $urls[] = route($name, [], true);
    }
}

Mirror the same loop in a Blade partial for <link rel="alternate" hreflang="..."> tags — build one helper that returns "all localized URLs for this route" and use it in both places so they can never drift.

Group by middleware, not by locale. Rather than Route::prefix('fr')->group(...) repeated per language, keep one route definition block and let the package/middleware apply the prefix. Fewer places to forget a route when adding Spanish.

Testing pattern. Write one test that loops over locales and asserts each named route resolves 200 and that route($name) matches the expected slug from the lang file. This catches missing translation keys immediately — a missing key usually surfaces as the raw key in the URL, which is worse than a 404.

it('resolves every public route in every locale', function () {
    foreach (config('translatable-routes.locales') as $locale) {
        app()->setLocale($locale);
        foreach (['about', 'pricing', 'contact'] as $name) {
            $this->get(route($name))->assertOk();
        }
    }
});

Integration with spatie/laravel-sitemap, signed URLs, and mailables. Anything that generates a URL outside a request must set locale explicitly first. Wrap it:

$url = app()->withLocale($user->locale, fn () => route('pricing'));

Upgrading across the 2.x line. 2.1.0 requires no application changes from 2.0 — it is a support-matrix release. Treat the bump as a CI exercise: add PHP 8.5 and Laravel 13 to your test matrix, run the locale × route test above, and confirm route:cache behaviour is unchanged (see Gotchas). If you are moving off 1.x, budget a real upgrade task — do the 2.0 changelog read first, run the same loop test, and expect slug/config renames rather than a drop-in composer update.


Gotchas and Tips

Locale is ambient state — queues and commands lose it. route() in a queued job, scheduled command, or Artisan command uses the app default locale, not the user's. This silently produces English URLs in French emails. Always set locale explicitly at the boundary (job handle(), mailable build()). This is the #1 bug with every translatable-route package.

Route caching is the sharp edge. php artisan route:cache bakes in the URIs generated at cache time. If the package resolves slugs from the current locale at registration, caching can freeze one locale's URLs for all locales. Test route:cache → hit /fr/... → route:clear as an explicit step in your deploy checklist before you rely on it in production. If it breaks, the workaround is registering routes for all locales up front rather than resolving lazily. Re-run this check after every version bump — a support-matrix release like 2.1.0 changes framework internals under the package, and route caching is where that surfaces first.

Missing translation keys leak into URLs. Depending on config, a missing lang/fr/routes.php entry can produce /fr/about (fallback), /fr/routes.about (raw key), or an exception. Decide which you want and assert it in a test. Never let a slug silently fall back to the English one on a page you've told Google is French — that's a duplicate-content signal.

Slug changes are URL changes. Renaming about-us → company breaks inbound links and rankings with no warning. Keep a redirect map (a simple lang/{locale}/route-redirects.php or a DB table + middleware) and treat slug edits like database migrations: reviewable, deployable, reversible.

URL::defaults() and parameter binding can conflict. If you use URL::defaults(['locale' => ...]), make sure it doesn't fight the package's own locale resolution. Pick one source of truth for "current locale" and make everything else read from it.

Middleware order matters. Locale middleware must run in the web group before anything that generates URLs (ShareErrorsFromSession won't care, but your view composer that builds the nav will). Register it early and cover it with a test that asserts the nav links are localized on the first request of a fresh session.

Watch the fallback locale carefully in Accept-Language negotiation. A German browser hitting a site with only en/fr should land on the default locale without a redirect loop. Guard the negotiation: if no supported locale matches, set the default and do not redirect to a prefix you didn't already have.

Debugging: route:list is your friend, per locale. There is no per-locale introspection in the normal tooling. A tiny Artisan command that loops locales and dumps name → URI will save you hours:

foreach (config('translatable-routes.locales') as $locale) {
    app()->setLocale($locale);
    $this->table(['name', 'uri'], collect(Route::getRoutes())
        ->filter(fn ($r) => $r->getName())
        ->map(fn ($r) => [$r->getName(), $r->uri()])->all());
}

Extension points and bus-factor risk. Still a single-maintainer package with no meaningful dependent ecosystem, so assume you may need to fork. Keep your integration thin: one route file, one middleware, one Blade helper. If the package's core is <500 lines, vendor it into app/Support/ behind your own interface so an upgrade or replacement is a one-file change. Favour the package's config and slug-translation files over subclassing its internals — those are the seams that will survive a rewrite.

Two healthy signals from 2.1.0 worth noting, though neither changes your integration: the maintainer is tracking new PHP/Laravel releases promptly (Laravel 13 support landed in the same cycle), and there is active CI/dependabot hygiene. The caveat is that this release's framework-support PR was authored by Copilot — verify against your own test matrix rather than trusting the version metadata, especially on PHP 8.5 where the surrounding ecosystem may lag.

Don't use it where it doesn't pay. Admin panels, APIs, and auth flows gain nothing from translated slugs and lose clarity. Scope it to the public, indexable surface.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
shitoudev/permissionmanager
johannesclimacus/laravel-tui
hocvt/seotools
vitalybaev/sypexgeo
amphibee/themosis-ignition
gito-me/fast-excel
frankyso/laravel-ipaymu
geolize-studio/laravel-chile
boomerangui/chat
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console