저는 로케일 하드코딩을 중단했고, 언어를 추가하는 것은 한 줄짜리 변경이 되었습니다.

작성자

카테고리:

← 피드로
DEV Community · Adrien Albuquerque · 2026-08-08 개발(SW)

Adrien Albuquerque

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' : '';

Enter fullscreen mode Exit fullscreen mode

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'],

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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'

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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
}

Enter fullscreen mode Exit fullscreen mode

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

  1. Put the locale list in one place on day one. Retrofitting it costs a weekend; starting with it costs nothing.
  2. Pick a canonical language for slugs immediately, even if you only have one language.
  3. Any time you write a translation lookup table, write down what its key is. Then check the next table uses the same key.
  4. 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.

원문에서 계속 ↗

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다