Multilingual Routing
Multilingual Routing
Section titled “Multilingual Routing”For Nitrogen sites in Hungary + EU markets, multilingual support (HU + EN typically) is standard. Routing decisions affect SEO heavily — incorrect locale detection or URL structure can devastate rankings in the secondary language.
URL structure choice
Section titled “URL structure choice”See URL Structure and Hreflang for the conceptual discussion. For Nitrogen sites, subdirectory pattern is the default:
example.com/ (English default, no prefix)example.com/hu/ (Hungarian)example.com/de/ (German)This page covers the Laravel-side implementation.
Laravel routing
Section titled “Laravel routing”// English (default, no prefix)Route::get('/', [HomeController::class, 'index']);Route::get('/about', [PageController::class, 'about']);Route::get('/services/{slug}', [PageController::class, 'service']);
// Localized routes with prefixRoute::prefix('{locale}') ->where(['locale' => 'hu|de']) ->middleware('set-locale') ->group(function () { Route::get('/', [HomeController::class, 'index']); Route::get('/about', [PageController::class, 'about']); Route::get('/services/{slug}', [PageController::class, 'service']); });The constraint where(['locale' => 'hu|de']) ensures only valid locales
match — any other prefix falls through to 404.
SetLocale middleware
Section titled “SetLocale middleware”namespace App\Http\Middleware;
use Closure;use Illuminate\Http\Request;use Illuminate\Support\Facades\App;
class SetLocale{ protected array $supported = ['en', 'hu', 'de'];
public function handle(Request $request, Closure $next) { $locale = $this->detectLocale($request); App::setLocale($locale);
// Make locale available to views view()->share('currentLocale', $locale);
return $next($request); }
protected function detectLocale(Request $request): string { // 1. URL prefix (highest priority — explicit user choice) $urlLocale = $request->route('locale'); if ($urlLocale && in_array($urlLocale, $this->supported)) { return $urlLocale; }
// 2. Cookie (returning user's preference) $cookieLocale = $request->cookie('locale'); if ($cookieLocale && in_array($cookieLocale, $this->supported)) { return $cookieLocale; }
// 3. Accept-Language header (browser preference) // Only used as fallback for the default URL, NEVER overrides URL prefix $browserLocale = $this->parseAcceptLanguage($request); if ($browserLocale && in_array($browserLocale, $this->supported)) { return $browserLocale; }
// 4. Default return config('app.fallback_locale', 'en'); }
protected function parseAcceptLanguage(Request $request): ?string { $header = $request->header('Accept-Language'); if (!$header) { return null; }
// Parse "hu-HU,hu;q=0.9,en;q=0.8" $languages = explode(',', $header); foreach ($languages as $lang) { $code = trim(explode(';', $lang)[0]); $base = explode('-', $code)[0]; // "hu-HU" → "hu" if (in_array($base, $this->supported)) { return $base; } }
return null; }}Critical SEO rule: never auto-redirect based on Accept-Language
Section titled “Critical SEO rule: never auto-redirect based on Accept-Language”A common mistake: a user lands on example.com/about (English page) but
their browser is set to Hungarian, so the server redirects to /hu/about.
This is wrong because:
- Googlebot crawls from US locations with
Accept-Language: enheaders. If you redirect non-EN users away, you’ve created a one-way trap for Hungarian users sharing links. - The user explicitly visited the English URL (e.g., from a link). Overriding that is hostile UX.
- Search Console treats Accept-Language-based redirects as suspicious.
Correct behavior:
- Accept-Language is only used to choose locale on the root path (
/) when no cookie is set - Once on a localized URL, never redirect to a different locale
- Users explicitly choose locale via the language switcher (which sets a cookie)
CMS-stored translations
Section titled “CMS-stored translations”CREATE TABLE pages ( id INT PRIMARY KEY AUTO_INCREMENT, slug VARCHAR(500) NOT NULL, locale VARCHAR(5) NOT NULL, title VARCHAR(500) NOT NULL, content LONGTEXT, parent_page_id INT NULL, -- the "main" translation source status VARCHAR(20) DEFAULT 'draft', published_at TIMESTAMP NULL, UNIQUE KEY uq_slug_locale (slug, locale), INDEX idx_parent (parent_page_id), FOREIGN KEY (parent_page_id) REFERENCES pages(id) ON DELETE SET NULL);parent_page_id enables the hreflang group: every translation links to a
“main” page, and from there you can iterate all available languages.
Identifying translation groups
Section titled “Identifying translation groups”class Page extends Model{ public function translationParent() { return $this->belongsTo(Page::class, 'parent_page_id'); }
public function translations() { return $this->hasMany(Page::class, 'parent_page_id'); }
public function allTranslations() { // The "main" page plus all its translations $rootId = $this->parent_page_id ?? $this->id;
return Page::where('id', $rootId) ->orWhere('parent_page_id', $rootId) ->where('status', 'published') ->get(); }}Hreflang generation
Section titled “Hreflang generation”public function generateHreflangs(Page $page): array{ return $page->allTranslations() ->map(function (Page $translation) { return [ 'lang' => $translation->locale, 'url' => $this->buildUrl($translation), ]; }) ->toArray();}
protected function buildUrl(Page $page): string{ if ($page->locale === config('app.fallback_locale')) { return url($page->slug); // no prefix for default locale } return url("{$page->locale}/{$page->slug}");}Render in Blade:
@php $hreflangs = app(\App\Services\HreflangService::class)->generateHreflangs($page);@endphp
@foreach($hreflangs as $hreflang) <link rel="alternate" hreflang="{{ $hreflang['lang'] }}" href="{{ $hreflang['url'] }}">@endforeach<link rel="alternate" hreflang="x-default" href="{{ url($page->slug) }}">Language switcher
Section titled “Language switcher”When the user clicks a language switcher, find the translation and redirect:
class LocaleController extends Controller{ public function switch(Request $request, string $locale) { if (!in_array($locale, ['en', 'hu', 'de'])) { abort(404); }
$previousUrl = $request->session()->previousUrl(); $previousPath = parse_url($previousUrl, PHP_URL_PATH) ?? '/';
// Try to find the equivalent page in the new locale $targetUrl = $this->findEquivalent($previousPath, $locale);
return redirect($targetUrl) ->cookie('locale', $locale, 60 * 24 * 365); // 1 year }
protected function findEquivalent(string $path, string $locale): string { // Strip locale prefix from path $cleanPath = preg_replace('#^/(hu|de)/#', '/', $path); $slug = trim($cleanPath, '/');
// Find the page by slug (in default locale) $page = Page::where('slug', $slug) ->where('locale', config('app.fallback_locale')) ->first();
if (!$page) { // No page found, just go to homepage in new locale return $locale === 'en' ? '/' : "/{$locale}"; }
// Find translation in target locale $translation = $page->translations() ->where('locale', $locale) ->where('status', 'published') ->first();
if ($translation) { return $locale === 'en' ? '/' . $translation->slug : "/{$locale}/" . $translation->slug; }
// No translation exists, return to root in target locale return $locale === 'en' ? '/' : "/{$locale}"; }}Filament admin for translations
Section titled “Filament admin for translations”In the page edit form, show the translation status:
Forms\Components\Section::make('Translations') ->schema([ Forms\Components\Placeholder::make('translations_status') ->content(function (Page $record) { $existing = $record->translations->pluck('locale')->toArray(); $missing = array_diff(['en', 'hu', 'de'], $existing); $missing = array_diff($missing, [$record->locale]); // exclude self
$existingHtml = collect($existing) ->map(fn($l) => "<span style='color:green'>✓ {$l}</span>") ->implode(', ');
$missingHtml = collect($missing) ->map(fn($l) => "<span style='color:red'>✗ {$l}</span>") ->implode(', ');
return new \Illuminate\Support\HtmlString("Existing: {$existingHtml}<br>Missing: {$missingHtml}"); }),
Forms\Components\Actions::make([ Forms\Components\Actions\Action::make('createTranslation') ->label('Create Hungarian translation') ->action(function (Page $record) { $translation = $record->replicate(); $translation->locale = 'hu'; $translation->parent_page_id = $record->id; $translation->status = 'draft'; $translation->title = '[HU] ' . $record->title; $translation->save();
return redirect()->to(PageResource::getUrl('edit', ['record' => $translation])); }) ->visible(fn (Page $record) => !$record->translations()->where('locale', 'hu')->exists()), ]), ]);This gives editors a clear view of which translations exist and easy creation of missing ones.
Configuration
Section titled “Configuration”'locale' => env('APP_LOCALE', 'en'),'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),'supported_locales' => ['en', 'hu', 'de'],// config/translatable.php — if using spatie/laravel-translatablereturn [ 'fallback_locale' => 'en', 'locales' => ['en', 'hu', 'de'],];Cross-references
Section titled “Cross-references”- Hreflang for Multilingual Sites — frontend annotation
- URL Structure — URL design for multilingual
- Slug Management — per-locale slug strategies
- CMS Content Workflow — managing translated content