<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Adrien Albuquerque</title>
    <description>The latest articles on DEV Community by Adrien Albuquerque (@adrien_albuquerque).</description>
    <link>https://dev.to/adrien_albuquerque</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4068773%2Fb94aefe1-87cd-4ca7-988c-bda35cbd8aac.png</url>
      <title>DEV Community: Adrien Albuquerque</title>
      <link>https://dev.to/adrien_albuquerque</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/adrien_albuquerque"/>
    <language>en</language>
    <item>
      <title>I stopped hardcoding locales, and adding a language became a one-line change</title>
      <dc:creator>Adrien Albuquerque</dc:creator>
      <pubDate>Sat, 08 Aug 2026 12:10:10 +0000</pubDate>
      <link>https://dev.to/adrien_albuquerque/i-stopped-hardcoding-locales-and-adding-a-language-became-a-one-line-change-5aj4</link>
      <guid>https://dev.to/adrien_albuquerque/i-stopped-hardcoding-locales-and-adding-a-language-became-a-one-line-change-5aj4</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$prefix&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$locale&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'en'&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="s1"&gt;'/en'&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule that fixed it
&lt;/h2&gt;

&lt;p&gt;One rule, enforced by a test: &lt;strong&gt;no locale literal anywhere outside the config file.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;config/locales.php&lt;/code&gt;. Nothing structural.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="s1"&gt;'default'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'fr'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="s1"&gt;'available'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'fr'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'en'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'pt-br'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'pt-pt'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'es'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three helpers cover essentially every call site:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;locale_prefix&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;          &lt;span class="c1"&gt;// '' for the default locale, '/es' otherwise&lt;/span&gt;
&lt;span class="nf"&gt;locale_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/disc'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;      &lt;span class="c1"&gt;// prefixed, canonical, ready to print&lt;/span&gt;
&lt;span class="nf"&gt;locale_slug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'groupe'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;    &lt;span class="c1"&gt;// the localized route segment&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The part nobody warns you about: slugs are data
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The decision that saved me: &lt;strong&gt;one locale is canonical, always.&lt;/strong&gt; French, in my case. Every slug in every other language resolves back to a French slug before anything else happens.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$slugs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;app&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;SlugService&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nv"&gt;$slugs&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toLocale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'quatre-tendances'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'es'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;      &lt;span class="c1"&gt;// 'cuatro-tendencias'&lt;/span&gt;
&lt;span class="nv"&gt;$slugs&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;resolveToCanonical&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'cuatro-tendencias'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 'quatre-tendances'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Which leads to the actual lesson.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the destination, not the presence
&lt;/h2&gt;

&lt;p&gt;I had tests asserting that every page emitted its &lt;code&gt;hreflang&lt;/code&gt; tags. All green. They looked like this, and some of mine still do:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertSee&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'hreflang="es"'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// proves a string is in the HTML&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That assertion never checks that the URL inside the tag &lt;strong&gt;resolves to anything&lt;/strong&gt;. A page was happily advertising &lt;code&gt;hreflang="es"&lt;/code&gt; pointing at a beautifully formed 404.&lt;/p&gt;

&lt;p&gt;The test that catches real bugs is the boring one. Follow the reference:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;alternate_urls&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$locale&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// follow it, do not admire it&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would tell past me
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Put the locale list in one place on day one. Retrofitting it costs a weekend; starting with it costs nothing.&lt;/li&gt;
&lt;li&gt;Pick a canonical language for slugs immediately, even if you only have one language.&lt;/li&gt;
&lt;li&gt;Any time you write a translation lookup table, write down what its key is. Then check the next table uses the same key.&lt;/li&gt;
&lt;li&gt;Assert that references resolve. "The tag is present" is not a test.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The site is &lt;a href="https://profilia.app/en" rel="noopener noreferrer"&gt;Profilia&lt;/a&gt; if you want to poke at the result. Free, no account, and the five language trees are the whole reason this post exists.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>laravel</category>
      <category>php</category>
      <category>i18n</category>
    </item>
  </channel>
</rss>
