codezero/laravel-uri-translator
A Laravel package for translating URIs, mapping localized URL paths and route segments so multilingual sites can expose language-specific slugs instead of one shared path. It plugs into Laravel's routing and translation layers to keep links consistent and SEO-friendly across locales.
Install
composer require codezero/laravel-uri-translator
The package is auto-discovered. Publish its config if you want to change the translation file name or lookup behaviour:
php artisan vendor:publish --tag="uri-translator-config"
Check composer.json first — with the last release in March 2024 and 6 stars, verify the Laravel/PHP constraints match your app before committing.
Where to look first
config/uri-translator.php — tells you which translation file/key namespace the package reads from (typically a uris.php file per locale).vendor/codezero/laravel-uri-translator/src/UriTranslator.php — the whole package is small; read it once. You'll see exactly how segments are split, looked up and reassembled, which is the key to predicting its behaviour.lang/{locale}/uris.php files — this is where all the day-to-day work happens.Create the translation files
// lang/en/uris.php
return [
'about-us' => 'about-us',
'contact' => 'contact',
'our-team' => 'our-team',
];
// lang/nl/uris.php
return [
'about-us' => 'over-ons',
'contact' => 'contact',
'our-team' => 'ons-team',
];
Keys are the "canonical" (usually default-locale) slugs; values are the slug in that locale's file. Keep the key stable — it is your internal identifier.
First use case: a language switcher
This is the package's sweet spot. Given the current request URI, produce the same page in another locale:
use CodeZero\UriTranslator\UriTranslator;
$current = request()->path(); // "nl/over-ons"
$target = UriTranslator::translate($current, 'en'); // "en/about-us"
// or via the helper, if registered
$target = uri_translate($current, 'en');
Set the locale before translating (see Gotchas), then build the link, redirect, or hreflang tag from the result. Verify the exact method signature and argument order against the source/README — this package has a single maintainer and the API surface is tiny, so a 30-second read of the class is worth it.
Don't hard-code translated paths in routes/web.php. Instead, generate them from the translator so a change to lang/nl/uris.php propagates everywhere:
// routes/web.php
Route::group(['prefix' => '{locale}', 'middleware' => 'set.locale'], function () {
Route::get(uri_translate('about-us'), [PageController::class, 'about'])->name('about');
Route::get(uri_translate('contact'), [PageController::class, 'contact'])->name('contact');
});
Because route registration happens per-request using the current locale, this only works cleanly when translations are resolved at boot. For anything non-trivial, prefer registering one canonical route and translating at the edge:
Route::get('/{slug}', PageController::class)->where('slug', '.*');
…with a middleware that maps the incoming localized slug back to a canonical page key. That keeps route:cache usable and route model binding intact.
The most common production pattern: accept any localized slug on the way in, normalise to a canonical key, and redirect anything that isn't canonical for the active locale.
class ResolveLocalizedUri
{
public function handle($request, Closure $next)
{
$locale = $request->segment(1) ?: config('app.locale');
app()->setLocale($locale);
$path = $request->path();
$canonical = UriTranslator::translate($path, config('app.fallback_locale'));
$expected = UriTranslator::translate($canonical, $locale);
if ($request->path() !== $expected) {
return redirect()->to('/'.$expected, 301); // canonicalise, avoid duplicate content
}
return $next($request);
}
}
Register it after your locale-detection middleware, otherwise you will translate using the wrong locale.
@foreach (config('app.locales') as $locale)
@php
$url = url(uri_translate(request()->path(), $locale))
. (request()->getQueryString() ? '?'.request()->getQueryString() : '');
@endphp
<a href="{{ $url }}" hreflang="{{ $locale }}" rel="alternate">
{{ strtoupper($locale) }}
</a>
@endforeach
The translator works on the path only — it will not preserve the query string or #fragment, so do that yourself.
Because the package returns the input unchanged for unknown segments, an untranslated slug will happily render in every locale and create duplicates. Pair the translator with:
<link rel="canonical" href="{{ url(UriTranslator::translate($canonicalKey, config('app.fallback_locale'))) }}"><link rel="alternate" hreflang="..."> per locale, generated by looping config('app.locales') and calling the translator.If you render large menus or sitemaps, memoise per-locale lookups. The package reads from Laravel's translator each call, which is cheap but not free in tight loops:
$uris = Cache::rememberForever("uris:{$locale}:".config('app.version'), fn () =>
collect(config('uris.keys'))->mapWithKeys(fn ($key) => [$key => uri_translate($key)])
);
Remember to bust the cache on deploy (version suffix above) — Laravel does not invalidate anything when you edit a lang file.
it('renders the Dutch about page', function () {
app()->setLocale('nl');
$this->get('/'.uri_translate('about-us'))->assertOk();
});
Add a test that asserts every key in your default-locale file exists in every other locale — that's the failure mode that actually bites in this package (silent pass-through of the English slug).
Locale must be set before you translate. The translator reads app()->getLocale(). Middleware ordering, App::setLocale() in a service provider, and queue jobs (which don't inherit the request locale) all matter. If translations "randomly" return English, this is almost always why.
Unknown slugs pass through unchanged. That's convenient (it keeps IDs, dates and non-translatable segments working) but dangerous: /nl/about-us will return 200 instead of 404, and you get duplicate content across locales. Decide explicitly whether to 404 or 301 on non-canonical paths, and add the test mentioned above.
Numeric/untranslated segments. ID-based URLs (/nl/products/42) work because 42 isn't in the map — but make sure your middleware only redirects on the slug segments, not on IDs, or you'll break route model binding with redirect loops.
Non-ASCII slugs. Dutch/German/French slugs (over-ons, impressum, à-propos) can arrive percent-encoded. Decode before lookup and re-encode when building URLs; otherwise the lookup misses and falls through to the untranslated value.
Trailing slashes and case. Translate the normalised path (trim($path, '/'), consistent case). /About-Us/ and /about-us should not take different code paths.
Slug collisions. Two different canonical keys mapping to the same translated value in one locale is silently accepted by the translation array but produces ambiguous incoming routing. Add a uniqueness assertion to your test suite.
Route caching. If you register routes using translated URIs computed at runtime, php artisan route:cache may bake in the wrong locale or fail outright in a queued/CLI context where no locale is set. Prefer canonical routes + middleware, and always run route:cache in a deploy step with an explicit default locale.
Config/lang caching. config:cache and route:cache after every deploy are mandatory; the package's config is read at boot. There is no lang:cache, so edited translation files take effect immediately in dev — which makes "works locally, not in prod" almost always a stale config cache.
Maturity risk. 6 stars, 0 dependents, single maintainer, last release 2024-03-10. Read the ~200 lines of source and pin the exact version in composer.json. Because the public surface is essentially one function plus a config file, forking or reimplementing it (a trans() call over lang/{locale}/uris.php) is a realistic fallback if it stalls — keep your own calls behind a thin service or helper so swapping it out is a one-file change.
Extension points. The config file and the translation namespace are the intended seams; there's no event or driver system. If you need per-tenant slugs, per-domain locales, or database-backed translations, wrap the package in your own resolver rather than modifying vendor code — and be aware the fallback behaviour (return input) will mask a missing tenant override unless you add explicit logging when a lookup misses.
How can I help you explore Laravel packages today?