Symfony: tutorial 19: idiomas, internacionalizando, el Locale

Aquí estoy de nuevo con otro code-kata sobre Symfony. Este post es un repaso en Symfony Flex con respecto a la traducción de idiomas. Es decir, en este post voy a tratar de ser conciso, sin demasiados detalles, para tratar el tema de tener una página traducida en varios idiomas. Es decir, una vez más, siguiendo la planificación sobre el repaso a Symfony, que llegamos a un tema un poco complicado, la internacionalización, que abreviado es I18N.

Con este post, si lo sigues mientras construyes tu proyecto, en cuestión de minutos podrás tener el esqueleto de una web multidioma totalmente funcional. Doy por sentado que llevamos bien el resto de temas como Doctrine, los controladores, el maker, diseño de bases de datos, etc.. Abordar la internacionalización de una página no es trivial.

Trataré de ir al grano, mientras que construimos el proyecto desde cero. Y todo será dinámico, podremos dar de alta tantos idiomas como queramos, y también dar de alta tantas páginas de contenido como queramos.

Empezamos, generando el esqueleto de la aplicación web

Como viene siendo costumbre, vamos a la línea de comandos y ponemos lo siguiente:

composer create-project symfony/skeleton symfony-i18n
cd symfony-i18n
composer require --dev server maker profiler
composer require twig doctrine translation yaml form validator security-csrf annotations

Yo suelo tener mi cajón desastre en el directorio de Descargas/, así lo marraneo todo lo que necesite, y borro cada x tiempo todo lo que haya. Entonces, con el siguiente comando podemos arrancar la aplicación web:

php bin/console server:start

Si todo ha ido bien, desde http://localhost:8000/ podremos ver el nuevo proyecto funcionando.

Un poco de teoría

Para comenzar tendremos dos cosas en mente siempre: el idioma que tiene el usuario, y las traducciones que tenemos disponibles para los contenidos.

El locale del navegador del visitante es lo que nos va a decir qué idioma o idiomas utiliza el visitante, y su orden de preferencia. Esto nos lo proporciona el navegador, que estará configurado, o habrá cogido la configuración en su instalación. También un navegador puede tener un plugin que te permita cambiar entre locales. Un plugin de estos es necesario, en Firefox este me funciona muy bien:

https://github.com/callahad/quick-accept-language-switcher

Por otro lado tendremos el locale del contenido que mostramos. De esta forma tendremos las traducciones de cada elemento, en tantos locales como queramos. Para estas traducciones Symfony nos invita a hacerlo de dos formas, es decir, definiremos los locale de los contenidos de dos formas:

  • Mediante ficheros de traducción en texto plano, que pueden ser en formato Yaml o Xliff.
  • O mediante la traducción y guardado de los contenidos en la base de datos. Estos contenidos necesitarán un locale para saber en qué idioma están.

A saber, utilizaremos ficheros en texto plano cuando traduzcamos palabras o frases simples que podemos traducir en formularios, botones, etc.. Pero utilizaremos la base de datos para traducir páginas completas, para textos grandes.

El esquema general de la aplicación web

Vamos al grano, que la documentación está muy bien para los detalles. Y aquí nos centramos con la construcción de un ejemplo paso a paso 😉 Primero vamos a crear un controlador principal:

php bin/console make:controller

..y le podemos dar un nombre como por ejemplo ‘MyDefaultController’. Ahora vamos a hacer una traducción simple en este controlador en el siguiente apartado.

Las traducciones de textos simples, sin locale en la URL

Para hacer esto es sencillo, en un controlador podremos traducir haciendo lo siguiente. En el fichero src/Controller/MyDefaultController.php recién creado editamos poniendo:

<?php

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Contracts\Translation\TranslatorInterface;

class MyDefaultController extends AbstractController
{
    /**
     * @Route("/", name="my_default")
     */
    public function index(Request $request, TranslatorInterface $translator)
    {
        $locale = $request->getLocale();

        $translated = $translator->trans('Texto a traducir');

        return $this->render('my_default/index.html.twig', [
            'locale' => $locale,
            'translated' => $translated,
        ]);
    }
}

En la plantilla templates/my_default/index.html.twig editamos el contenido para mostrar estas dos variables anteriores: locale y translated. Me ha quedado así:

{% extends 'base.html.twig' %}

{% block title %}Hello MyDefaultController!{% endblock %}

{% block body %}
<style>
    .example-wrapper { margin: 1em auto; max-width: 800px; width: 95%; font: 18px/1.5 sans-serif; }
    .example-wrapper code { background: #F5F5F5; padding: 2px 6px; }
</style>

<div class="example-wrapper">
    <h1>Hello! ✅</h1>

    <ul>
        <li>Your locale is <code>{{ locale }}</code></li>
        <li>Your translated variable is <code>{{ translated }}</code></li>
        <li>A text translated into the Twig template is <code>{% trans %}Texto a traducir{% endtrans %}</code></li>
    </ul>
</div>
{% endblock %}

Mira que he añadido una tercera forma de traducir en plantillas Twig, que es mediante {% trans %}Texto{% endtrans %}.

Ahora mismo al acceder a http://localhost/ no veo ni mi locale correcto ni el texto traducido. Tenemos que ver algo como lo siguiente:

No hay problema, esto tiene su porqué. Vamos a comenzar por configurar en el fichero config/services.yaml, que es el principal de configuraciones, antes era el parameters.yml.

Dejo aquí un truco del almendruco, que te ahorrará bastante tiempo escudriñando la documentación y foros. Con esto definimos los locales disponibles y las preferencias desde el punto de vista de la aplicación web:

parameters:
    locale: 'es'
    languages: ['es','en','fr','pt','it','de']

Y en la configuración del fichero config/packages/translation.yaml tendremos cómo las traducciones van a coger estos datos definidos.

framework:
    default_locale: '%locale%'
    translator:
        paths:
            - '%kernel.project_dir%/translations'
        fallbacks:
            - '%locale%'

Antes de seguir conviene que mires tus ficheros recién creados con el maker y las configuraciones que dejo aquí arriba hechas. Con estas configuraciones simplemente le estamos diciendo al traductor que el locale por defecto es el de la variable parameters que se llama locale. Y el fallback de las traducciones, es decir, cuando no encuentre una traducción, que también la traduzca al locale definido.

Por otro lado tenemos los locales, que luego vamos a usar en el controlador que redirige al visitante, en orden de preferencia, al mejor idioma que le corresponde.

Ya con esto tenemos que ver lo siguiente:

Symfony 19 traduciendo parte 2

Lo siguiente es hacer unos ficheros de traducciones para probar este proyecto. He cambiado la frase ‘Texto a traducir’ por ‘Text to translate’ tanto en plantilla como en el controlador, ya que debemos de programar y hacer los textos originales en inglés por convención internacional.

Entonces en línea de comandos vamos y escribimos lo siguiente para generar los ficheros de traducciones iniciales:

php bin/console translation:update --dump-messages --force --output-format yml es
php bin/console translation:update --dump-messages --force --output-format yml en
php bin/console translation:update --dump-messages --force --output-format yml fr
php bin/console translation:update --dump-messages --force --output-format yml pt
php bin/console translation:update --dump-messages --force --output-format yml it
php bin/console translation:update --dump-messages --force --output-format yml de

Esto nos generará los ficheros simples a traducir en directorio translations/ como en la imagen siguiente:

Symfony 19 generando ficheros de traducciones

Si queremos generar estos ficheros en formato XLIFF que son los más usados en los programas de traductores, podemos usar el siguiente comando:

php bin/console translation:update --dump-messages --force --output-format xlf es

El formato XLIFF tiene el aspecto de la imagen siguiente:

Symfony 19 extrayendo cadenas a traducir en formato XLIFF

Personalmente, yo prefiero usar el formato YAML, porque tenemos cada texto simple a traducir en una sola línea.

Ya después de esto podemos empezar a traducir los textos simples en estos ficheros. Si vamos al navegador, actualizamos, y vemos lo siguiente:

Vale, hasta aquí todo perfecto, ya puedo traducir textos simples con los ficheros YAML. Pero queremos hacerlo lo mejor posible y que el SEO de la página sea bueno. Para esto entonces debemos de poner la variable especial llamada _locale en todas las URLs.

Conviene que no sigas y repases lo anterior, haciendole perrerías a tu proyecto en local para ver los resultados antes de seguir..

El punto de entrada, añadiendo el locale en la URL

Como decíamos terminando el apartado anterior, si poneme en la URL el locale, una única URL siempre tendrá el mismo resultado. Esto hará que se indexe la web en buscadores mejor.

Entonces, desde la páginas de inicio, conviene comenzar por redirigir al idioma que le toque a cada visitante. Desde http://localhost:8000/ que nos va a redirigir a http://localhost:8000/es/, http://localhost:8000/en/, http://localhost:8000/fr/, etcétera..

Para esto propongo lo siguiente como punto de entrada a la web. En la home /, primero este controlador, que nos redirige según locale del navegador en orden de prefererencias:

<?php

namespace App\Controller;

use Symfony\Component\Routing\Annotation\Route;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Session\Session;

class DefaultController extends Controller
{
    /**
     * @Route("/", name="homepage")
     */
    public function index(Session $session, Request $request)
    {
        $languages = $this->container->getParameter('languages');
        //var_dump($languages); exit;

        return $this->redirectToRoute('my_default', [
            '_locale' => $request->getPreferredLanguage($languages),
        ]);
    }
}

Y en el controlador anterior modificamos las URLs que nos pueden quedar como lo siguiente:

<?php

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Contracts\Translation\TranslatorInterface;

/**
 * @Route("/{_locale}")
 */
class MyDefaultController extends AbstractController
{
    /**
     * @Route("/", name="my_default")
     */
    public function index(Request $request, TranslatorInterface $translator)
    {
        $locale = $request->getLocale();

        $translated = $translator->trans('Text to translate');

        return $this->render('my_default/index.html.twig', [
            'locale' => $locale,
            'translated' => $translated,
        ]);
    }
}

El resultado es el mismo de antes en la página. Pero la navegación sigue los siguientes pasos:

  • Se consulta http://localhost:8000/ en donde se recibe en servidor los locales del navegador.
  • Se procesan los locales del navegador, eligiendo uno por defecto en función a los disponibles. Los disponibles se cargan desde el fichero config/services.yaml en los parametros que habíamos puesto antes.
  • En mi caso, me ha redirigido finalmente a http://localhost:8000/es/, si cambiamos con el complemente del navegador nuestro código de idioma, nos deberá de redirigir al correspondiente.

Con un plugin de navegador podemos cambiar la cabecera llamada Accept-Language a valor en y veremos este comportamiento:

  • http://localhost:8000/ que redirige a http://localhost:8000/en/

Sigamos ahora haciendo contenidos extensos y traducidos.

Diseñando la base de datos de una web en varios idiomas

Recapitulando, lo anterior nos sirve para pequeñas traducciones, para textos en títulos, botones, formularios, etc.. También nos sirve para redirigir al navegador a la URL con idioma y para indexar mejor en buscadores, para tener un buen SEO.

Vamos ahora a diseñar la BD para almacenar contenidos de páginas estáticas multiidiomas. Lanzamos el maker de Doctrine para hacer una entidad llamada Page que contenga los siguientes campos:

  • locale
  • urlKey
  • title
  • content

Vamos a línea de comandos y ejecutamos:

php bin/console make:entity

Me remito a un post anterior sobre Doctrine y la creación de entidadades para más información sobre este paso si es necesario.

Vamos de paso a hacer un CRUD con el generados de códigos de Symfony. Lanzamos lo siguiente de la imagen en línea de comandos:

Symfony 19 i18n generando CRUD de páginas..

Ya con esto podemos generar tantas páginas como necesitemos accediendo a la URL http://localhost/es/page/ pero antes tenemos que enganchar el proyecto en local a la base de datos. Para esto tenemos que editar el fichero .env poniendo algo como lo siguiente en la línea de configuración de la BD:

DATABASE_URL=mysql://usuario:contraseña@127.0.0.1:3306/symfony_i18n

Y en línea de comandos ejecutamos:

php bin/console doctrine:database:create
php bin/console doctrine:schema:create

Ya tenemos con esto la BD creada y su tabla para almacenar las páginas si todo ha ido bien. Ahora editamos el controlador del CRUD, porque hemos metido el locale en todas las URLs. Sino no nos funcionará:

<?php

namespace App\Controller;

use App\Entity\Page;
use App\Form\PageType;
use App\Repository\PageRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

/**
 * @Route("/{_locale}/page")
 */
class PageController extends AbstractController
{

Ahora sí, podremos generar unas páginas con contenidos en la URL http://localhost/es/page/ mediante el CRUD.

Terminando, generando unas páginas de pruebas y mostrando su contenido al navegar

Genero un par de páginas para probar en local, una en español y otra en inglés.

Ya sólo queda hacer la función en el controlador y el Twig correspondiente que pinte los contenidos de las páginas. Para el controlador, he metido esta función en MyDefaultController.php. La función me ha quedado así:

/**
     * @Route("/{urlKey}", name="page_show_content")
     */
    public function showPageContent(Request $request, $urlKey) {
        $locale = $request->getLocale();
        $entityManager = $this->getDoctrine()->getManager();
        $page = $entityManager->getRepository('App\Entity\Page')->findOneBy([
            'urlKey' => $urlKey,
            'locale' => $locale,
        ]);

        var_dump($page);

        return $this->render('page/showContent.html.twig', [
            'page' => $page,
        ]);
    }

Simplemente busca por _locale y clave de URL la página que toca. Lo he dejado sucio con el var_dump para que pruebes. Lo siguiente es el Twig en el fichero templates/page/showContent.html.twig, algo como lo siguiente es necesario:

<h1>{{ page.title }}</h1>
{{ page.content | raw }}

Ahora accediendo a la URL http://localhost:8000/es/pagina-de-prueba que tenemos que ver algo como lo siguiente:

Symfony 19 i18n internacionalizando.. mostrando página..

Esto es todo. Menudo testamento de post que me ha quedado. Si has llegado hasta aquí abajo espero haberte podido ayudar a aclarar las ideas sobre la internacionalización. Sólo queda remitirme a la documentación oficial de Symfony, en donde se tratan todos estos puntos por separado:
https://symfony.com/doc/current/translation.html

Espero que con la documentación oficial, y este code-kata, ya puedas hacer con éxito tantas páginas multidioma como necesites. Para cualquier cosa no dudes en dejar un comentario. ¡Un saludo!

Compartir..

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *