Plain source, for humans in a hurry and machines in general.
Most multilingual Laravel applications are built the same way: an English route table with a locale prefix bolted on the front. It works, and it leaves the non-English versions unable to rank for anything anyone searches for.
Why the slug has to be translated
A Dutch prospect looking for pricing searches tarieven. The URL is a genuine, if modest, relevance signal, and more importantly it is the thing shown in results, shared in messages and read out by assistants. /nl/pricing announces a translation of an English site. /nl/tarieven announces a Dutch page.
The same applies to the whole path. This site runs /pricing, /nl/tarieven and /es/precios; /ai-development, /nl/ai-development and /es/desarrollo-ia. The Dutch market uses the English term for that one, so translating it would be worse.
That last detail is the real point: slug choice is a keyword decision per language, not a translation task. It cannot be automated, and it is the reason the slugs belong in translation files where a human can set them individually.
How to build it
Put the slugs in a translation file per locale, one entry per page:
// lang/nl/routes.php
return [
'pricing' => 'tarieven',
'about' => 'over',
'ai-development' => 'ai-development',
];
Then register the routes once, resolving each URI through the translator, inside whatever localised-route mechanism you use. Every locale gets its own route with its own path and a locale-prefixed name, so route('pricing') gives the current locale’s URL and links are correct everywhere without a single conditional in a template.
Two rules make this hold up over time. Never rename a slug that already ranks. There is no upside, and you inherit a redirect to maintain forever. And keep the redirect table for old URLs as data in one array rather than a stack of one-off closures, so it can be asserted in full by a test.
The hreflang mistakes that matter
This is where multilingual sites break most often, and the failures are silent.
The set must be reciprocal and self-referencing. Every locale’s page lists every locale including itself. If the Dutch page points at English and Spanish but the English page does not point back at Dutch, search engines discard the relationship.
x-default is not optional. It tells the engine what to serve someone none of your locales fit. Point it at the language with the widest reach, usually English.
Canonicals stay within the locale. The Dutch page’s canonical is the Dutch URL. A canonical pointing from the Dutch page to the English one is an instruction to drop the Dutch page from the index, and it is a remarkably common accident.
Only list locales that exist. If a blog post has an English version and no Dutch one, do not emit a Dutch alternate. This is why translations need a shared identifier (the filename, or a key) that is independent of the translated slug, so the system can tell whether a translation exists rather than assuming it does.
Never redirect a crawler on Accept-Language
Auto-redirecting first-time visitors to their browser’s language is good for humans and disastrous if it also applies to bots. A crawler arriving with a European IP and no language preference gets bounced into one locale, and the others go uncrawled and unindexed.
Three rules keep it safe: exempt known crawler user agents entirely; only ever redirect a first visit, gated on a cookie, so a visitor who chose a language keeps it; and always use a temporary redirect, never a permanent one, since the destination depends on the visitor.
Testing the thing you cannot see
Two tests catch most of it.
Assert that every route resolves in every locale, returns 200, and renders exactly one h1. Drive it from the locale config so that adding a language extends the coverage automatically. If adding a locale requires a code change anywhere but a config entry and a language directory, the architecture is wrong and this test is what tells you.
Then assert the hreflang sets: for every page in every locale, the set contains every locale, contains itself, includes x-default, and every URL in it resolves. Reciprocity is exactly the kind of property that is tedious to check by hand and trivial to check in a loop.
The short version
Translated slugs from translation files. One route registration for all locales. Reciprocal, self-referencing hreflang with x-default. Canonicals inside the locale. Bots exempt from language redirects. Tests that iterate the locale list rather than hardcoding it.
Do those and adding a fourth language is a directory and a config entry, which is the only reasonable definition of done here.
Questions people also ask
Subdirectories, subdomains or separate domains?
Should the default language have a prefix?
Is machine translation good enough?
How do we know a locale is broken?
Who wrote this
Alexander (Sander) van Hooff
Alexander (Sander) van Hooff has been building web applications since 2015 and now spends most of his time getting AI features into products that already have users. Vuewer is his studio, run from the Alicante region of Spain for clients across Europe.
Work with VuewerRelated reading
Getting Core Web Vitals green on a real application
What actually moves LCP, INP and CLS on an application with a decade of code behind it, in the order that gets the most improvement for the least risk.
Why we still choose Laravel in 2026
An honest argument for a ten-year-old framework: what Laravel does better than the alternatives, what it does worse, and the cases where we would pick something else.
SEO, AEO and GEO: what actually changed
Three acronyms, one shift: search engines now answer instead of link. What that changes about how pages should be written, and what it does not change at all.