Accents and ñ in slugs: Why an article returns a 404
An article goes live, its title looks fine… but the public link returns a 404. Nothing in the PHP error logs, nothing wrong in the database: the article definitely exists. The culprit is a single letter, the ñ in “La Niña”. This happened on our blog in early November 2026, and the cause is worth examining step by step.
The principle is simple: an article's address is generated from its title, creating what is known as a slug (or alias). If the generating function lets through a character the web server cannot route, the article becomes unreachable even though everything looks correct.
The scenario: a slug that keeps its ñ
Our alias-generation function replaced accented characters one by one using a handwritten table: é becomes e, à becomes a, ç becomes c, and so on. It covered French well, but not Spanish or Portuguese: ñ, í, ó, ú, ò, ö and ã slipped through unchanged. The title “La Niña: the hidden economic risk behind El Niño” therefore produced an alias that still contained ñ.
On the server, the .htaccess file routes articles with a rule like this:
RewriteRule ^article/([a-zA-Z0-9-_]+)$ /index.php?page_theme=article&alias=$1 [L]The [a-zA-Z0-9-_] class accepts only ASCII letters, digits, hyphens and underscores. The browser sends ñ encoded as %C3%B1; Apache decodes it before matching, and the resulting UTF-8 bytes clearly do not belong to that class. The rule does not match, no other rule takes over, and a 404 page is returned.
The detail that makes things worse: the end anchor
A few days earlier, we had added $ to the end of the pattern. Without it, the rule matched only the beginning of an address: for /article/la-niña-…, it captured la-ni and silently discarded the rest. Depending on how the CMS resolves a partial alias, this can produce the wrong page, a duplicate page in Google's eyes, or a baffling 404. The anchor makes the failure explicit, which is better, but it does not fix the cause: the slug itself must be clean.
Why strtolower won't save you
Some of our replacements targeted only lowercase letters, assuming everything had already been lowercased. But strtolower() only handles ASCII: since PHP 8.2, it no longer even depends on the locale and leaves Ñ and É untouched. An uppercase title such as “EL NIÑO” therefore retained its accent even after the lowercase mapping was fixed. You must either handle uppercase letters explicitly or use mb_strtolower($titre, 'UTF-8').
UTF-8, latin1 and replacements that match nothing
Two other traps await any homemade replacement table.
- Double encoding. If a title passed through a MySQL connection using latin1 while the database used UTF-8 (or vice versa), ñ becomes
ñ. Yourstr_replace('ñ', 'n', …)no longer finds a match and the slug contains corrupted fragments. Check the connection character set (utf8mb4everywhere) before blaming the function. - Regular expressions without the
umodifier.preg_replace('/[àâä]/', 'a', $s)works byte by byte: the class contains six bytes, so “à” (two bytes) becomes “aa”. With/[àâä]/u, PCRE operates on characters and gives the correct result.
Generating clean slugs in PHP
Rather than endlessly expanding a replacement table, use Unicode transliteration followed by a strict allowlist.
The robust approach: the intl extension
function slugifier(string $titre): string
{
// Transliterate any script to Latin, then ASCII, and lowercase it
$s = transliterator_transliterate('Any-Latin; Latin-ASCII; Lower()', $titre);
// Allowlist: replace anything other than a-z or 0-9 with a hyphen
$s = preg_replace('/[^a-z0-9]+/', '-', $s);
return trim($s, '-');
}This handles ñ, ß (which becomes ss), œ, ø and ł, and even non-Latin scripts, which Any-Latin romanizes. It does not depend on the server's locale.
Alternatives and their limitations
iconv('UTF-8', 'ASCII//TRANSLIT', $s)depends on the active locale: withoutsetlocale(LC_CTYPE, 'fr_FR.UTF-8'), glibc produces “Ni?a”, and the result is even less reliable on Alpine (musl).Normalizer::normalize($s, Normalizer::FORM_D)followed bypreg_replace('/p{Mn}+/u', '', $s)removes accents, but leaves œ, æ, ß and ø, which are not accented letters in the Unicode sense.- A handwritten table remains acceptable if the intl extension is unavailable, provided it ends with the same allowlist: that is what guarantees the result.
The real rule: slugs and rewrites must agree
The underlying problem was that we had two different definitions of an “allowed character”: the implicit one in the PHP function and the explicit one in .htaccess. The lesson is simple: the slug generator's allowlist must match the rewrite rule's character class exactly. If .htaccess accepts [a-z0-9-], the function must end with preg_replace('/[^a-z0-9]+/', '-', …), without exceptions.
One extra tip: put the hyphen last in a character class, as in [a-zA-Z0-9_-]. Placing it in the middle may work thanks to the engine's tolerance, but it can look like a range and eventually trip someone up.
Prevention is better than repair
- Test tricky titles: “La Niña”, “EL NIÑO”, “Ça coûte 5 €”, “Œuvre complète”, “Straße”, and a Japanese title. Every slug must match the Apache rule's pattern.
- Provide a fallback for empty slugs: a title made entirely of symbols produces an empty string. Use the article ID instead.
- Audit existing aliases: a query such as
SELECT alias FROM articles WHERE alias NOT REGEXP '^[a-z0-9-]+$'quickly reveals addresses that are already broken. - Check after publishing: a simple HTTP request to the public URL, which should return 200, would have caught the problem immediately.
What about SEO?
Google can index URLs containing Unicode characters, so ñ in an address is not inherently forbidden. But once copied into a message or social network, the address often appears encoded, with uninviting strings such as %C3%B1. A short, readable ASCII slug remains the safest choice, provided the generating function filters everything properly.
In our case, the fix took three steps: complete the transliteration (lowercase and uppercase), correct the affected article's alias in the database, and keep the end anchor in .htaccess so any future mistake is visible instead of being silently truncated.
English
French
German
Spanish
Hindi
Italian
Japanese
Korean
Norwegian
Chinese


