Desarrollamos tu web presencial SPA DESDE 300€. Si, es una locura. Web SPA DESDE 300€ — Si, es una locura. Hablemos →

Cómo traducir las URLs de tu web sin duplicar las rutas en cada idioma

Cómo traducir las URLs de tu web sin duplicar las rutas en cada idioma

Cuando una web tiene que hablar tres idiomas, la parte de las URLs se resuelve casi siempre en cinco minutos y mal, porque la solución rápida parece funcionar. La factura llega en el posicionamiento, meses después. Aquí verás cómo declarar los tres caminos una sola vez, por qué el idioma tiene que ir delante en el nombre de la ruta y qué se rompe si lo pones detrás. Los ejemplos van en Laravel porque es una de nuestras especialidades en AndorraDev, pero el planteamiento sirve igual en cualquier framework con enrutado nombrado.

Un prefijo de idioma no es una URL traducida

La primera solución que aparece cuando alguien pide una web multiidioma es meter el idioma como parámetro de ruta:

Route::prefix('{locale}')->where(['locale' => 'es|ca|fr'])->group(function () {
    Route::get('/search', [SearchController::class, 'index'])->name('search');
});

Una sola declaración y funciona. El problema es lo que produce: /es/search, /ca/search, /fr/search. El idioma cambia y el camino no.

Y ese camino es contenido. Alguien que busca en catalán no escribe "search", escribe "cercar". La palabra clave en la URL es una señal pequeña pero real, y si además tu competencia local la tiene y tú no, es una señal que regalas. Lo que quieres es /buscar, /ca/cercar y /fr/rechercher.

El idioma tiene que ir en el nombre de la ruta, y delante

Nombrar rutas parece una cuestión de gusto hasta que hay tres por página. A partir de ahí, cómo compongas ese nombre decide si puedes resolver el idioma con una operación de texto o necesitas una tabla de equivalencias.

Si registras tres rutas para el mismo destino, cada una necesita un nombre distinto. Hay dos formas de componerlo y no son equivalentes.

Como sufijo: contact, contact.ca, contact.fr. Es lo intuitivo y tiene una consecuencia inmediata: route('contact') devuelve siempre la versión en el idioma por defecto, así que hay que escribir un ayudante propio y llamarlo en todas partes.

Como prefijo: es.contact, ca.contact, fr.contact. Parece lo mismo y no lo es, porque permite algo que el sufijo no permite: interceptar la resolución de nombres de forma genérica. Con el idioma delante, un generador de URLs puede probar primero {locale}.{nombre} y caer al nombre pelado si no existe.

public function route($name, $parameters = [], $absolute = true)
{
    $locale = app()->getLocale();

    if (! $this->isAlreadyLocalized($name)) {
        if ($route = $this->routes->getByName("{$locale}.{$name}")) {
            return $this->toRoute($route, $parameters, $absolute);
        }
    }

    return parent::route($name, $parameters, $absolute);
}

Con eso, route('web.contact') devuelve la URL correcta en el idioma activo sin cambiar ni una llamada en las vistas.

Declarar los tres caminos en un sitio

La declaración queda así, con las tres traducciones juntas y a la vista:

LocalizedRoute::get('/contact', [
    'en' => '/contact',
    'es' => '/contacto',
    'ca' => '/contacte'
], function () {
    return view('web.contact');
})->name('web.contact');

Eso registra tres rutas reales, con sus nombres prefijados por idioma, y aplica al idioma por defecto un prefijo vacío para que se sirva en la raíz.

Funciona igual con parámetros, incluidos los enlaces con clave personalizada:

LocalizedRoute::get('/search/{location:slug}/{propertyFamily:slug}', [
    'en' => '/search/{location:slug}/{propertyFamily:slug}',
    'es' => '/buscar/{location:slug}/{propertyFamily:slug}',
    'ca' => '/cercar/{location:slug}/{propertyFamily:slug}'
], [SearchController::class, 'index'])->name('web.search');

Y lo importante de mantenimiento: las tres traducciones están en la misma pantalla. Cuando alguien cambia el slug español, ve al lado el catalán y el inglés, así que no se olvida de ninguno.

Sustituir el generador de URLs es lo que evita tocar las vistas

Al comparar soluciones se mira lo que hace falta escribir, y casi nunca lo que hace falta reescribir. Tu proyecto ya está lleno de llamadas a route(), y no todas las escribiste tú.

Puedes conseguir el mismo resultado con un ayudante propio, del tipo transroute('contact'), y funciona perfectamente para las llamadas que escribes tú. El problema son las que no escribes tú.

El paginador genera enlaces llamando a route(). Las notificaciones del framework generan enlaces llamando a route(). Los paquetes de autenticación generan enlaces llamando a route(). Ninguno va a llamar a tu ayudante.

Sustituyendo el generador de URLs de la aplicación, todo eso hereda el comportamiento gratis. Y hay un caso concreto que ilustra por qué compensa: las URLs firmadas también se localizan solas, porque el método que las genera llama internamente a route().

Andie recomienda

Decide una sola cosa y mantenla: o el idioma por defecto vive en la raíz, o todos llevan prefijo. Lo primero es lo habitual y lo que mejor posiciona, porque tu idioma principal se queda con las URLs limpias y los demás quedan claramente separados. Configurado así desde el principio, no hay que tocar redirecciones más adelante.

Fuera de una petición HTTP no hay idioma que valga

Todo el mecanismo se apoya en el idioma activo de la aplicación, y ese idioma lo pone un middleware al mirar la URL. Basta con salir de una petición HTTP para que ese middleware no exista, y ahí es donde empiezan a salir enlaces en el idioma que no era.

La resolución automática usa el idioma activo de la aplicación, que lo fija un middleware a partir de la URL. En un trabajo en cola, en un comando de consola o al generar el enlace de una notificación no ha pasado ningún middleware, así que el idioma activo es el de por defecto y la URL sale en ese idioma.

Y ese es justo el caso en que más importa acertar, porque estás generando un enlace para el idioma del destinatario, no para el tuyo. La solución es explícita y hay que asumirla:

$locale = $client->locale;

$url = URL::temporarySignedRoute(
    "{$locale}.client.password.setup",
    now()->addDays(7),
    ['subdomain' => $subdomain]
);

Componer el nombre a mano ahí no es una carencia, es la consecuencia lógica de que el idioma se deduzca del contexto: fuera de una petición no hay contexto del que deducirlo.

Por qué esto importa especialmente en Andorra

Andorra es de los pocos sitios donde una web en tres idiomas no es un capricho, es el escenario normal.

El catalán es la única lengua oficial, y a la vez se hablan castellano, portugués y francés. Una web comercial seria cubre al menos catalán, castellano y francés, y eso significa tres conjuntos de URLs compitiendo cada uno en su idioma.

Con caminos traducidos tienes tres páginas con la palabra clave correcta en la URL y con etiquetas hreflang que se enlazan entre sí de forma limpia. Con un prefijo y el mismo camino en todos, tienes tres versiones de la misma URL en inglés. Lo desarrollamos en el artículo sobre webs multiidioma en Andorra y en posicionamiento web.

Qué alternativas hay y en qué se diferencian

Las tres opciones reales, con su compromiso honesto:

El prefijo con parámetro de ruta es la más barata y la que menos código añade. Si tus URLs son técnicas o tu producto vive detrás de un inicio de sesión, donde nadie va a buscar por esas palabras, es perfectamente razonable y no hay motivo para complicarla.

El bucle manual sobre los idiomas, declarando cada ruta una vez por idioma, es explícito y no añade dependencias. Con un número modesto de páginas se lleva perfectamente y es una opción del todo razonable. El coste aparece con el volumen: cada ruta se escribe tantas veces como idiomas tengas, y el ayudante que resuelve los nombres solo cubre las llamadas propias.

mcamara/laravel-localization es la referencia conocida del ecosistema para este problema. La diferencia de enfoque que conviene valorar es dónde vive la traducción del camino: en ficheros de traducción aparte, o en la propia declaración de la ruta. Con muchas rutas, tenerlas centralizadas en ficheros tiene su lógica; con un número manejable, verlas juntas en la declaración evita saltar entre dos sitios para cambiar un slug.

Lo que a nosotros nos decidió fue la sustitución del generador de URLs, porque es lo que hace que el resto del framework se entere sin tocar nada.

Cómo se ve en producción

En Abodara hay 53 rutas localizadas declaradas en tres ficheros de rutas, en tres idiomas, incluyendo grupos con subdominio.

Y el número que mejor mide si el enfoque funciona: en sus vistas hay 774 llamadas a route() y ninguna lleva prefijo de idioma. Todas usan el nombre neutro y la resolución ocurre sola. Eso es exactamente lo que quieres, porque significa que añadir un cuarto idioma no toca ni una vista.

Y si no trabajas con Laravel

Los criterios se trasladan a cualquier framework con enrutado por nombre:

  • Traduce el camino, no solo el prefijo, si tus páginas son públicas y compiten en buscadores.
  • Pon el idioma delante en el identificador de la ruta. Es lo que permite resolverlo de forma genérica en vez de caso a caso.
  • Intercepta la generación de URLs, no las llamadas. Si tu solución obliga a usar una función especial, todo lo que genere enlaces desde dentro del framework se te queda fuera.
  • Y asume que fuera de una petición hay que decir el idioma explícitamente. Los correos son el caso, y es donde más se nota si te equivocas.

Dónde está el código

El paquete está en Packagist, el código en GitHub y la documentación del paquete recoge la configuración y los middlewares disponibles. Es MIT, sin tablas y sin comandos: solo un registrador de rutas, un generador de URLs y un puñado de middlewares para fijar el idioma según la URL, la sesión o el navegador.

Si estás montando una web en varios idiomas para el mercado andorrano, esta decisión se toma el primer día y condiciona el resto. La trabajamos en desarrollo Laravel y posicionamiento web, y si nos cuentas cuántos idiomas y cuántas páginas te decimos qué enfoque compensa.

Escrito por
Edu Lazaro
Edu Lazaro
Founder & Lead Developer en AndorraDev

Desarrollador full-stack con más de 15 años de experiencia en Laravel, React, Node.js y arquitecturas cloud. Ayudo a empresas en Andorra a construir su presencia digital.

Partner de diseño · ionospace.
Necesitas ayuda? ×
Andie by AndorraDev
Asistente IA + equipo humano
Asistente IA de AndorraDev
Andie
Hola! Soy Andie, el asistente IA de AndorraDev. ¿En qué puedo ayudarte? Si necesitas hablar con Edu, solo pídelo.
17:28