8 MIN LECTURA · Pedro Thomaz

Construir un sitio multilingüe en PHP sin framework

Cómo servimos EN/PT/ES desde una única base de código PHP renderizada en el servidor — enrutamiento por prefijo de URL, diccionario de cadenas, hreflang y la trampa de detección de idioma que rechazamos.

Construir un sitio multilingüe en PHP sin framework

Se puede construir un sitio totalmente multilingüe en PHP sin framework, sin paso de build y sin una tabla de base de datos por idioma. Servimos EN/PT/ES desde una única base de código PHP renderizada en el servidor usando tres piezas pequeñas: enrutamiento por prefijo de URL (/pt/, /es/), un diccionario de cadenas plano e i18n por campo en el CMS — además de hreflang correcto y una regla obstinada sobre la detección de idioma.

Versión corta: el idioma vive en la URL, nunca en una cookie ni en una conjetura. El idioma por defecto no lleva prefijo. Todo lo demás es una capa fina de funciones auxiliares sobre PHP simple.

Por qué un sitio multilingüe en PHP sin framework

El sitio del estudio corre sobre PHP 8.3 en alojamiento compartido de OVH, detrás de Cloudflare, con Cockpit CMS v2 (autoalojado, SQLite) para los contenidos. Sin Laravel, sin Symfony, sin pipeline de build en Node. Las páginas se renderizan en el servidor y se sirven tal cual. Esa restricción es deliberada: menos piezas móviles significan un sitio más rápido, una superficie de ataque menor y una base de código que un solo ingeniero puede tener en la cabeza.

La internacionalización suele ser donde el "sin framework" asusta a la gente, porque los ecosistemas de frameworks venden catálogos de mensajes, middleware de locale y traducción de rutas como un problema resuelto. Resulta que la superficie real es pequeña. Un sitio multilingüe necesita responder cuatro preguntas: ¿qué idioma es esta petición?, ¿cómo escribo un enlace que se mantenga en ese idioma?, ¿cómo traduzco una cadena? y ¿cómo le digo a Google que estas páginas son el mismo contenido en idiomas distintos? Cada una es una función o dos.

1. El idioma vive en el prefijo de la URL

Nuestro esquema de URLs es toda la base:

El Inglés no lleva prefijo. Es una decisión real con consecuencias: mantiene limpias las URLs canónicas/por defecto, pero implica que el Inglés nunca puede ser "un idioma más" en el código — siempre es la rama de fallback. Creemos que ese compromiso es correcto para un estudio con sede en Portugal cuyo público por defecto lee Inglés.

En producción (Apache/OVH) el prefijo se elimina en el .htaccess y el idioma se pasa como parámetro de query al mismo archivo PHP que habría servido el Inglés:

# prefijo de URL i18n: /pt/<resto> o /es/<resto> → quita prefijo, pasa lang en la QS
RewriteRule ^(pt|es)$        $1/ [R=302,L]
RewriteRule ^(pt|es)/(.*)$   $2?lang=$1 [QSA,L]
RewriteRule ^(pt|es)/?$      ?lang=$1   [QSA,L]

Así, /pt/work se convierte internamente en work.php?lang=pt. Una plantilla, tres idiomas. No existe ningún directorio /pt/ en disco — y ese orden importa enormemente, como veremos.

Para el desarrollo local con php -S no hay .htaccess, así que un router.php replica la misma regla en PHP antes de cualquier enrutamiento:

// Detecta prefijo /pt/ /es/ → quita + define $_GET['lang']
if (preg_match('#^/(pt|es)(/.*|$)#', $uri, $lm)) {
    $_GET['lang'] = $lm[1];
    $uri = $lm[2] === '' ? '/' : $lm[2];
}

Mantener el router de desarrollo y los rewrites de producción al unísono es la fuente más común de bugs de i18n del tipo "funciona en local, se rompe en producción". Los tratamos como una única regla lógica expresada en dos dialectos.

2. Una función para leer el idioma

Toda plantilla necesita saber el idioma actual. Eso es una función cacheada en lib/lang.php. Confía en el parámetro de query que fijó el rewrite y, si falta, vuelve a analizar la ruta de la URL como fallback de seguridad:

function lang(): string {
    static $cached = null;
    if ($cached !== null) return $cached;
    $l = (string)($_GET['lang'] ?? '');
    if ($l === '') {
        $uri = parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH);
        if (preg_match('#^/(pt|es)(/|$)#', $uri ?? '', $m)) $l = $m[1];
    }
    if (!in_array($l, ['en','pt','es'], true)) $l = 'en';
    return $cached = $l;
}

La última línea es la frontera de seguridad y de corrección: cualquier cosa que no sea un locale de la lista permitida colapsa a Inglés. Ninguna cadena suministrada por el usuario llega a una ruta de archivo o a una clave de diccionario sin comprobar.

3. Enlaces que se mantienen en el idioma actual

La trampa de cualquier esquema de prefijos es escribir /pt/work a mano en las plantillas. Hazlo una vez y el enlace de un traductor devolverá a un lector portugués al sitio en Inglés. En cambio, todos los enlaces internos pasan por link_to(), que añade el prefijo según el locale actual y se niega a duplicarlo o a tocar URLs externas:

function link_to(string $path): string {
    $l = lang();
    if (preg_match('#^(https?:|mailto:|tel:|//)#', $path)) return $path; // externo
    if (str_starts_with($path, '#')) return $path;                       // ancla
    // ¿ya tiene prefijo? déjalo
    foreach (['pt','es'] as $sup) {
        if (str_starts_with($path, '/'.$sup.'/') || $path === '/'.$sup) return $path;
    }
    if ($l === 'en') return $path;          // EN = sin prefijo
    if ($path === '/' || $path === '') return '/'.$l.'/';
    return '/'.$l.$path;
}

Las plantillas siempre escriben link_to('/work'). En Inglés obtienen /work; en Portugués obtienen /pt/work; y un /pt/work pasado por error queda intacto en lugar de convertirse en /pt/pt/work. La idempotencia es la clave.

4. Dos tipos de traducción: cromo vs. contenido

El contenido multilingüe se divide nítidamente en dos categorías, y cada una pide un almacenamiento distinto.

Cromo de interfaz — etiquetas de navegación, texto de botones, el pie de página, la página 404 — es finito, vive en el código y rara vez cambia. Lo guardamos en un único array PHP plano en lib/ui-strings.php, indexado por idioma y luego por una clave de cadena con puntos, y lo leemos con t():

function t(string $key, ?string $lang = null): string {
    static $dict = null;
    if ($dict === null) $dict = require __DIR__ . '/ui-strings.php';
    $lang = $lang ?? lang();
    return $dict[$lang][$key] ?? $dict['en'][$key] ?? $key;
}

La cadena de fallback lo es todo: idioma pedido → Inglés → la propia clave. Una cadena portuguesa ausente degrada a Inglés, nunca a un vacío o a un crash; y una clave mal escrita se renderiza como nav.work en lugar de desaparecer, lo que hace que el bug sea ruidoso en vez de silencioso. El diccionario es un simple require — la caché de opcode de PHP lo mantiene en memoria, así que no hay coste de parsing por petición ni I/O.

Contenido editorial — artículos del journal, casos de estudio, descripciones de servicios — es abierto y lo editan personas, así que vive en Cockpit. En lugar de duplicar registros enteros por idioma, guardamos traducciones por campo: un campo es o una cadena simple (igual en todos los idiomas) o un pequeño mapa como {"en": "...", "pt": "...", "es": "..."}. Un auxiliar tr() resuelve cualquiera de las dos formas:

function tr($value, ?string $lang = null) {
    if (!is_array($value)) return $value;          // no localizado → tal cual
    $lang = $lang ?? lang();
    return $value[$lang] ?? $value['en'] ?? reset($value);
}

Por eso el artículo que estás leyendo tiene title_en/title_pt/title_es y campos de cuerpo correspondientes: el mismo registro, traducido campo a campo, resuelto en tiempo de renderizado. Un slug, una fecha o una imagen no necesitan traducción y siguen siendo un escalar simple. Añadir un cuarto idioma más adelante significa añadir una clave a los mapas — no migrar un esquema.

5. Decirle la verdad a los buscadores con hreflang

Tres URLs sirviendo el mismo artículo en tres idiomas es, para un crawler ingenuo, contenido duplicado. hreflang es la forma estándar de decir "estos son traducciones unos de otros, sirve el correcto." Cada página emite el conjunto recíproco completo desde lib/seo.php, tras quitar cualquier prefijo existente para no componer nunca /pt/pt/:

$rootPath = preg_replace('#^/(pt|es)(/|$)#', '/', $path);
$ptPath = $rootPath === '/' ? '/pt/' : '/pt' . $rootPath;
$esPath = $rootPath === '/' ? '/es/' : '/es' . $rootPath;
// → 

Las reglas que mantenemos:

6. La trampa que rechazamos: redirigir automáticamente por navigator.language

Esta es la que hicimos mal primero, arreglamos y sobre la que ahora tenemos una opinión firme. La idea "servicial" es: en la primera visita, leer el idioma del navegador y mandar al usuario automáticamente a /pt/ o /es/. Enviamos exactamente eso, leyendo navigator.language.slice(0,2) en nuestro JS de arranque y redirigiendo.

Fue un desastre silencioso. navigator.language refleja la lista de preferencias del navegador, no el país ni la intención del usuario. Visitantes en Portugal con Español en cualquier punto de las preferencias del navegador eran forzados a /es/ en la primera carga. Peor aún, la redirección escribía la elección en localStorage, volviendo el locale equivocado persistente entre sesiones sin salida obvia — la página de inicio se abría a partir de entonces en un idioma que no pidieron, siempre.

Lo arrancamos. El prefijo de URL es ahora el único camino hacia un locale no inglés en el arranque, y subimos la clave de almacenamiento (amp-langamp-lang-v2) para invalidar los valores atascados que ya había en los navegadores de los visitantes recurrentes.

El principio: deja que el usuario elija su idioma explícitamente y haz que la elección sea visible en la URL. Una URL /pt/ limpia, compartible y marcable le gana a una conjetura ingeniosa siempre. Si quieres pistas a nivel de país, muestra un banner descartable — nunca una redirección silenciosa que secuestre la barra de direcciones. (Accept-Language y la Content-Negotiation del lado del servidor tienen exactamente el mismo modo de fallo; tampoco los usamos para enrutar.)

7. El bug de orden que pilla a todo el mundo

Un último detalle ganado a pulso. En el .htaccess, el rewrite del prefijo i18n debe ejecutarse antes de la comprobación de existencia de archivo. Este sitio tiene años de URLs heredadas de WordPress, algunas bajo rutas literales /pt/ y /es/ del CMS antiguo. Si la pasada de Apache "¿existe este archivo/directorio?" se ejecutara primero, esos directorios fantasma eclipsarían el rewrite y servirían contenido obsoleto en lugar de enrutar a la plantilla localizada. Las redirecciones heredadas 301/410 van por delante de ambas, así que una URL antigua muerta se retira antes de que cualquier lógica de locale la toque. El orden de las reglas en el .htaccess no es cosmético — es el flujo de control.

Recapitulación en FAQ

¿Hace falta un framework para un sitio multilingüe en PHP? No. Idioma-desde-la-URL, una consulta a diccionario, un auxiliar de enlaces y hreflang correcto son, en total, unas pocas docenas de líneas.

¿Dónde debe vivir el idioma? En la ruta de la URL. No en una cookie, una sesión ni una conjetura sobre el idioma del navegador. Las URLs siguen siendo compartibles y los buscadores pueden indexar cada idioma.

¿Cómo se evitan las penalizaciones por contenido duplicado? hreflang recíproco en cada página, x-default hacia tu idioma por defecto y canónicos autorreferenciales por locale.

¿Hay que autodetectar el idioma del usuario? No redirijas en base a eso. navigator.language y Accept-Language describen preferencias, no intención, y acertar mal es persistente y exasperante. Deja que la URL decida.