Multilingual SEO in Laravel: hreflang, Localized URLs and Sitemaps
You translated the app into four languages. Six months later, organic traffic from Spain is unchanged. Nothing is broken — but if your locale lives in a session or a cookie, then as far as a crawler is concerned you have one English site with one URL per page, and three languages nobody can find.
Getting multilingual content indexed is a separate job from translating it, and the requirements are specific.
Every language needs its own URL
This is the non-negotiable part. A crawler has no session and doesn't set cookies. If /pricing returns Spanish to Spanish users and English to everyone else, Google indexes whichever version it saw and the other three don't exist.
Three structures work. In rough order of preference for most apps:
Subdirectory — example.com/es/pricing. One domain accumulating all your authority, trivial to implement, easy to add languages. This is the right default.
Subdomain — es.example.com/pricing. Reasonable if the sites are genuinely separate operations with separate teams or infrastructure.
ccTLD — example.es/pricing. Strongest local signal, most expensive: separate domains, separate authority to build, separate everything. Worth it for country-level businesses, rarely for a SaaS.
What doesn't work: ?lang=es as the only differentiator (crawled inconsistently, often treated as the same page), and anything relying on Accept-Language or a cookie to vary content at a single URL.
Routing subdirectories in Laravel
A prefix group with a locale parameter, plus middleware:
// routes/web.php
Route::prefix('{locale}')
->whereIn('locale', config('app.available_locales'))
->middleware(SetLocale::class)
->group(function () {
Route::get('/pricing', [PageController::class, 'pricing'])->name('pricing');
Route::get('/blog/{slug}', [BlogController::class, 'show'])->name('blog.show');
});
whereIn matters: without it, {locale} swallows any first segment, so /pricing would be interpreted as locale pricing and 404 confusingly. Constraining the parameter means unmatched paths fall through to your non-prefixed routes.
Then set a default so you don't thread ['locale' => ...] through every route() call:
final class SetLocale
{
public function handle(Request $request, Closure $next): Response
{
$locale = str_replace('-', '_', (string) $request->route('locale'));
App::setLocale($locale);
Carbon::setLocale($locale);
Number::useLocale($locale);
// Every route() call now fills {locale} automatically.
URL::defaults(['locale' => $locale]);
return $next($request);
}
}
URL::defaults() is the piece people miss, and without it every link in every template needs the locale passed explicitly.
Decide what happens at the root
/pricing with no prefix needs a defined behaviour. Two defensible options:
Redirect to a negotiated locale — 302 to /en/pricing, using Accept-Language. Use a 302, not a 301: the target varies by user, and a permanently-cached redirect to one language is a real problem.
Serve the default language canonically — /pricing is the English version, and only other languages get prefixes. Fewer redirects, cleaner default URLs, but asymmetric routing.
Either is fine. What breaks is having both /pricing and /en/pricing serve identical content with no canonical — that's duplicate content you created yourself.
hreflang
hreflang tells search engines these URLs are translations of each other, so the right one surfaces in the right market. Every page must list every language version including itself, and the references must be reciprocal.
{{-- resources/views/components/hreflang.blade.php --}}
@props(['routeName', 'parameters' => []])
@foreach(config('app.available_locales') as $locale)
<link rel="alternate"
hreflang="{{ str_replace('_', '-', $locale) }}"
href="{{ route($routeName, [...$parameters, 'locale' => $locale]) }}">
@endforeach
<link rel="alternate" hreflang="x-default"
href="{{ route($routeName, [...$parameters, 'locale' => config('app.locale')]) }}">
Details that determine whether this works at all:
Hyphens, not underscores. pt-BR, never pt_BR. Same normalization as the lang attribute.
Absolute URLs. Relative hrefs are ignored.
Self-referential. The Spanish page includes a Spanish hreflang. Omitting it invalidates the set.
Reciprocal. If /es/pricing points at /de/pricing, the German page must point back. One-directional annotations get dropped, and this is the single most common reason hreflang silently does nothing.
x-default for the fallback shown to users whose language you don't serve.
Generate these from one source of truth — your locale config — rather than hand-writing them per page. Hand-written hreflang drifts the moment you add a language.
Canonical tags
Each localized page is canonical to itself. The mistake is pointing every translation at the English original:
{{-- Wrong: tells Google the Spanish page shouldn't be indexed --}}
<link rel="canonical" href="{{ route('pricing', ['locale' => 'en']) }}">
{{-- Right --}}
<link rel="canonical" href="{{ url()->current() }}">
Canonical says "index this one instead." Canonicalising Spanish to English asks Google to drop your Spanish page, which defeats the entire exercise. Canonical handles duplicates within a language; hreflang handles equivalents across languages. They're different jobs and shouldn't be confused.
Sitemaps
List every localized URL. A sitemap that only contains English tells crawlers there's one language:
<url>
<loc>https://example.com/en/pricing</loc>
<xhtml:link rel="alternate" hreflang="es" href="https://example.com/es/pricing"/>
<xhtml:link rel="alternate" hreflang="de" href="https://example.com/de/pricing"/>
</url>
The xhtml:link annotations are an alternative to putting hreflang in the HTML head. Doing both is fine and gives you redundancy if one is misconfigured.
Generate the sitemap from your route and content data, not by hand:
public function sitemap(): Response
{
$locales = config('app.available_locales');
$urls = collect(['pricing', 'features', 'about'])
->crossJoin($locales)
->map(fn ([$page, $locale]) => [
'loc' => route($page, ['locale' => $locale]),
'alternates' => collect($locales)->map(fn ($alt) => [
'hreflang' => str_replace('_', '-', $alt),
'href' => route($page, ['locale' => $alt]),
]),
]);
return response()
->view('sitemap', ['urls' => $urls])
->header('Content-Type', 'application/xml');
}
Worth adding a test that every URL in your sitemap returns 200 and doesn't redirect. Sitemaps full of 301s and 404s are common and they waste crawl budget on exactly the pages you're trying to promote.
Translate your metadata
Easy to forget because it isn't visible on the page: title, meta description, Open Graph tags and structured data are all user-facing text.
<title>{{ __('seo.pricing.title') }}</title>
<meta name="description" content="{{ __('seo.pricing.description') }}">
<meta property="og:title" content="{{ __('seo.pricing.og_title') }}">
<meta property="og:locale" content="{{ str_replace('_', '-', app()->getLocale()) }}">
An untranslated meta description is a Spanish result in Google with English descriptive text underneath — which measurably hurts click-through even when the page itself is fine. Metadata is also where character limits bite: German titles run substantially longer than English, so a title that fits in 60 characters in English may be truncated in translation. Worth checking rather than assuming.
Slugs
Should URLs themselves be translated — /es/precios rather than /es/pricing? It helps a little, and it costs real complexity: route definitions per locale, redirect maps when slugs change, and a harder time correlating analytics across languages.
My honest read: translate slugs for content where the slug carries a keyword (blog posts, landing pages, category pages), and keep structural paths in English (/dashboard, /settings). The SEO gain on a settings page is nil, and the maintenance cost is the same.
If you do translate slugs, store them per locale on the model and resolve by locale, keeping old slugs redirecting so links don't rot.
Partial translation and what to do about it
A practical question the guides rarely address: what do you serve when a page exists in English but not yet in German?
Three options, in descending order of how much I'd recommend them.
Don't expose the URL at all. If /de/blog/some-post has no German translation, return 404 and omit it from the sitemap and from the hreflang set on the other language versions. Nothing is indexed, nothing is broken, and the page appears the moment it's translated. This is the cleanest answer and it requires your routing to know what's translated.
Serve the source language with correct annotations. /de/pricing renders English content, the canonical points at the English URL, and there's no German hreflang. You've told search engines this is the English page, which is true. Acceptable as a transitional state; the risk is that it's easy to leave in place and forget.
Machine-translate as a placeholder. Fine for interface strings. For content pages, unreviewed translation published at scale is what the spam policies below are about — so if you do this, make sure something tracks which pages are still unreviewed.
What you should not do is include a language in your hreflang set when that page falls back to English. You've asserted a German equivalent exists; Google will serve it to German searchers, who bounce. That's worse than not having the page, because it trains the algorithm that your German results are poor.
This is where coverage reporting stops being a nice-to-have. Deciding which URLs to expose per language requires knowing, per page, whether the strings behind it are actually translated — and lang files don't tell you that without a script.
Common misconfigurations
Worth checking these explicitly, since each silently disables part of the setup:
| Mistake | Effect |
|---|---|
| hreflang="pt_BR" (underscore) | Invalid tag, ignored |
| Relative href in hreflang | Ignored |
| Missing self-reference | Whole set may be discarded |
| Non-reciprocal annotations | Annotations dropped |
| Canonical pointing to English | Translated page deindexed |
| Locale only in ?lang= | Inconsistently crawled |
| noindex inherited on localized routes | Nothing indexed, silently |
| Sitemap listing only the default locale | Other languages undiscovered |
| Redirecting /pricing → /en/pricing with 301 | Permanently caches one language |
The last one deserves emphasis because it's a genuinely destructive mistake. A 301 tells caches and crawlers the redirect is permanent, so a user-dependent redirect gets cached and everyone lands on the same language regardless of their preferences. Use 302 for anything that varies by user.
Google Search Console's international targeting report surfaces reciprocity errors specifically, which is the failure hardest to spot by reading your own HTML — worth checking after any change to your locale set.
Don't machine-translate and walk away
One thing worth saying because it affects rankings directly: unreviewed machine translation published at scale is the kind of content Google's spam policies target. Machine translation as a starting point, reviewed by someone who reads the language, is entirely normal and fine. Thousands of auto-generated pages nobody has read is a risk.
The practical version: use AI to get to a first draft fast, then have a human pass over anything on a page you want to rank. That's cheap for interface strings and worth budgeting properly for landing pages and content.
Checklist
- One URL per language. Subdirectories by default. Cookie- and header-based switching is invisible to crawlers.
- Constrain the
{locale}route parameter withwhereIn, and setURL::defaults(). - Define root behaviour deliberately: 302 to a negotiated locale, or make the default language canonical at the bare path.
- hreflang on every page: all languages, self-referential, reciprocal, absolute URLs, hyphens, plus
x-default. - Canonical points at itself, never at the English original.
- Sitemap lists every localized URL, ideally with
xhtml:linkalternates. Test they all return 200. - Translate
<title>, meta description and OG tags — and check length after translation. - Translate content slugs; leave structural paths alone.
- Review machine translation before publishing at scale.
The infrastructure here is a one-time build. Keeping four languages' worth of titles, descriptions and page copy complete and current is the recurring part — and a missing meta description is invisible until you check. LangSyncer shows coverage per language across every key including your SEO strings, fills gaps with AI for review, and publishes corrections without a deploy.