Acentos y ñ en los slugs: por qué un artículo devuelve 404
Publicamos un artículo, el título se ve bien… pero el enlace público devuelve un 404. Nada en los registros de errores de PHP, nada extraño en la base de datos: el artículo existe. El culpable es una sola letra, la ñ de «La Niña». Nos ocurrió en este blog a principios de noviembre de 2026, y merece la pena analizar la causa paso a paso.
El principio es sencillo: la dirección de un artículo se genera a partir de su título y se conoce como slug (o alias). Si la función generadora deja pasar un carácter que el servidor web no sabe enrutar, el artículo se vuelve inaccesible aunque todo parezca correcto.
El escenario: un slug que conserva la ñ
Nuestra función de generación de alias sustituía las letras acentuadas una a una mediante una tabla manual: é se convierte en e, à en a, ç en c, etcétera. Cubría bien el francés, pero no el español ni el portugués: ñ, í, ó, ú, ò, ö y ã quedaban intactas. Por tanto, el título «La Niña: el riesgo económico oculto detrás de El Niño» producía un alias que todavía contenía la ñ.
En el servidor, el archivo .htaccess enruta los artículos con una regla como esta:
RewriteRule ^article/([a-zA-Z0-9-_]+)$ /index.php?page_theme=article&alias=$1 [L]La clase [a-zA-Z0-9-_] solo admite letras ASCII, números, guiones y guiones bajos. El navegador envía la ñ codificada como %C3%B1; Apache la decodifica antes de comparar, y los bytes UTF-8 resultantes no pertenecen a esa clase. La regla no coincide, ninguna otra toma el relevo y aparece la página 404.
El detalle que empeora todo: el ancla final
Unos días antes habíamos añadido $ al final del patrón. Sin él, la regla solo comprobaba el comienzo de la dirección: para /article/la-niña-…, capturaba la-ni y descartaba el resto sin avisar. Según cómo resuelva el CMS un alias parcial, puede aparecer una página equivocada, una página duplicada a ojos de Google o un 404 desconcertante. El ancla hace evidente el error, lo cual es mejor, pero no corrige su origen: el slug debe estar limpio.
Por qué strtolower no te salvará
Algunas sustituciones solo contemplaban minúsculas, porque suponíamos que el texto ya se había convertido a minúsculas. Pero strtolower() solo procesa ASCII: desde PHP 8.2 ni siquiera depende de la configuración regional y deja Ñ y É intactas. Por eso un título en mayúsculas como «EL NIÑO» conservaba la tilde incluso después de corregir las minúsculas. Hay que tratar expresamente las mayúsculas o usar mb_strtolower($titre, 'UTF-8').
UTF-8, latin1 y sustituciones que no encuentran nada
Hay otras dos trampas en cualquier tabla de sustitución casera.
- La doble codificación. Si un título pasó por una conexión MySQL en latin1 mientras la base de datos estaba en UTF-8 (o viceversa), la ñ se convierte en
ñ. Entoncesstr_replace('ñ', 'n', …)ya no encuentra coincidencias y el slug contiene restos corruptos. Comprueba el juego de caracteres de la conexión (utf8mb4en todas partes) antes de culpar a la función. - Las expresiones regulares sin el modificador
u.preg_replace('/[àâä]/', 'a', $s)trabaja byte a byte: la clase contiene seis bytes y «à» (dos bytes) se convierte en «aa». Con/[àâä]/u, PCRE procesa caracteres y el resultado es correcto.
Cómo generar slugs limpios en PHP
En lugar de ampliar indefinidamente una tabla, conviene utilizar la transliteración Unicode y después filtrar mediante una lista estricta de caracteres permitidos.
La opción robusta: la extensión intl
function slugifier(string $titre): string
{
// Translitera cualquier escritura a latín, luego a ASCII y a minúsculas
$s = transliterator_transliterate('Any-Latin; Latin-ASCII; Lower()', $titre);
// Lista permitida: sustituye por guiones todo lo que no sea a-z o 0-9
$s = preg_replace('/[^a-z0-9]+/', '-', $s);
return trim($s, '-');
}Esta versión procesa ñ, ß (que pasa a ss), œ, ø y ł, e incluso escrituras no latinas, que Any-Latin romaniza. No depende de la configuración regional del servidor.
Alternativas y sus límites
iconv('UTF-8', 'ASCII//TRANSLIT', $s)depende de la configuración regional activa: sinsetlocale(LC_CTYPE, 'fr_FR.UTF-8'), glibc devuelve «Ni?a», y en Alpine (musl) el resultado es todavía menos fiable.Normalizer::normalize($s, Normalizer::FORM_D)seguido depreg_replace('/p{Mn}+/u', '', $s)elimina los acentos, pero conserva œ, æ, ß y ø, que no son letras acentuadas en el sentido de Unicode.- Una tabla manual sigue siendo válida si la extensión intl no está disponible, siempre que termine aplicando la misma lista de caracteres permitidos: es lo que garantiza el resultado.
La regla fundamental: el slug y la reescritura deben coincidir
El problema de fondo era que teníamos dos definiciones distintas de «carácter permitido»: la implícita en la función PHP y la explícita en .htaccess. La lección es sencilla: la lista de caracteres permitidos del generador debe coincidir exactamente con la clase de la regla de reescritura. Si .htaccess admite [a-z0-9-], la función debe terminar con preg_replace('/[^a-z0-9]+/', '-', …), sin excepciones.
Un consejo adicional: coloca el guion al final de la clase, como en [a-zA-Z0-9_-]. En medio puede funcionar por tolerancia del motor, pero parece un intervalo y tarde o temprano confundirá a alguien.
Más vale prevenir que corregir
- Prueba con títulos complicados: «La Niña», «EL NIÑO», «Ça coûte 5 €», «Œuvre complète», «Straße» y un título japonés. Cada slug debe ajustarse al patrón de la regla Apache.
- Prepara una alternativa para slugs vacíos: un título formado solo por símbolos produce una cadena vacía. Utiliza entonces el identificador del artículo.
- Revisa los alias existentes: una consulta como
SELECT alias FROM articles WHERE alias NOT REGEXP '^[a-z0-9-]+$'revela en segundos las direcciones que ya están rotas. - Comprueba después de publicar: una simple petición HTTP a la URL pública, que debería responder con 200, habría detectado el problema al instante.
¿Y el posicionamiento SEO?
Google puede indexar URL con caracteres Unicode, así que una ñ en una dirección no está prohibida por sí misma. Pero al copiarla en un mensaje o una red social, suele aparecer codificada, con secuencias poco atractivas como %C3%B1. Un slug ASCII breve y legible sigue siendo la opción más segura, siempre que la función generadora filtre correctamente.
En nuestro caso, la solución consistió en tres pasos: completar la transliteración (minúsculas y mayúsculas), corregir en la base de datos el alias del artículo afectado y conservar el ancla final en .htaccess para que cualquier error futuro sea visible en vez de quedar truncado en silencio.
Spanish
French
German
English
Hindi
Italian
Japanese
Korean
Norwegian
Chinese


