Most i18n tutorials stop at "put your strings in a JSON file". That covers labels. It does not cover the thing that actually breaks: URLs.
I run a personality test site in five locales (French, English, Brazilian Portuguese, European Portuguese, Spanish). Same site, five language trees, roughly 96 articles each. The first version had the shape every project seems to grow by accident:
$prefix = $locale === 'en' ? '/en' : '';
That line, or a cousin of it, spread across controllers, views and helpers. Every one of them was correct when written and wrong the moment a third locale showed up. The bug it produces is the worst kind: nothing throws, the page renders, and the Spanish version quietly links to French URLs.
The rule that fixed it
One rule, enforced by a test: no locale literal anywhere outside the config file.
Not in controllers, not in views, not in helpers. If code needs to know something about a locale, it asks the config. Adding a locale then becomes: content files, plus one entry in config/locales.php. Nothing structural.
'default' => 'fr',
'available' => ['fr', 'en', 'pt-br', 'pt-pt', 'es'],
Three helpers cover essentially every call site:
locale_prefix() // '' for the default locale, '/es' otherwise
locale_url('/disc') // prefixed, canonical, ready to print
locale_slug('groupe') // the localized route segment
The part nobody warns you about: slugs are data
Labels translate. Slugs are a different problem, because a slug is simultaneously a URL, a cache key, an SEO asset and a foreign key into your own content.
The decision that saved me: one locale is canonical, always. French, in my case. Every slug in every other language resolves back to a French slug before anything else happens.
$slugs = app(SlugService::class);
$slugs->toLocale('quatre-tendances', 'es'); // 'cuatro-tendencias'
$slugs->resolveToCanonical('cuatro-tendencias'); // 'quatre-tendances'
Without that pivot you get N-to-N translation tables and, eventually, two tables keyed differently. I know because it happened: one lookup table was keyed on accented slugs, and a later one, added when Spanish arrived, on ASCII slugs. Everything passed. Five Spanish pages just silently vanished from the sitemap, and nothing failed until someone fetched the live XML by hand.
Which leads to the actual lesson.
Test the destination, not the presence
I had tests asserting that every page emitted its hreflang tags. All green. They looked like this, and some of mine still do:
$response->assertSee('hreflang="es"', false); // proves a string is in the HTML
That assertion never checks that the URL inside the tag resolves to anything. A page was happily advertising hreflang="es" pointing at a beautifully formed 404.
The test that catches real bugs is the boring one. Follow the reference:
foreach (alternate_urls() as $locale => $url) {
expect($this->get($url)->status())->toBe(200); // follow it, do not admire it
}
Same idea for scoring data, for sitemaps, for internal links. Presence assertions feel like coverage and catch nothing. Assertions that follow the reference catch the class of bug that actually ships.
What I would tell past me
- Put the locale list in one place on day one. Retrofitting it costs a weekend; starting with it costs nothing.
- Pick a canonical language for slugs immediately, even if you only have one language.
- Any time you write a translation lookup table, write down what its key is. Then check the next table uses the same key.
- Assert that references resolve. "The tag is present" is not a test.
The site is Profilia if you want to poke at the result. Free, no account, and the five language trees are the whole reason this post exists.
Top comments (0)