Internationalisation dans un projet Symfony2 [i18n]

Internationalisation et localisation d'un projet Symfony2.

Publié le 27/11/2015

Ce tutoriel vise à internationaliser un projet Symfony2, c'est à dire de proposer la gestion de plusieurs langues dans le back-end comme dans le front-end.

Pour cela on va distinguer deux type de traductions :

  • - Les traductions statiques représentant les éléments d'interfaces utilisé dans le site
  • - Les contenus en base de données

Ce deux types de traductions ne seront pas gérés de la même façon :

  • - Les traductions statiques vont s'appuyer sur des dictionnaires en .yml
  • - Les contenus en DB vont bénéficier de nouveaux champs et tables

On va avoir besoin du bundle suivants :

  • - jms/i18n-routing-bundle

1 - Installation du bundle

Ajouter la ligne suivante dans la section require du fichier composer.json du projet :

"jms/i18n-routing-bundle": "~1.1"

Updater les vendors :

php composer.phar update

1.1 - Activation et paramétrage du bundle

Activation du bundle dans le kernel

nano app/AppKernel.php

Ajouter la ligne suivante dans le tableau $bundle de la méthode registerBundle() :

new JMS\I18nRoutingBundle\JMSI18nRoutingBundle(),

Ajout du paramétrage dans le fichier config.yml :

1.2 Paramétrage des locales

On va avoir besoin de deux langues pour notre projet : français et anglais. On va pour cela définir deux locales.

S'assurer que dans le fichier config.yml la locale par défaut est bien définie dans la section framework, puis on ajout la directive de configuration du bundle jms_i18n_routing :


framework:
    translator:      ~
    default_locale:  "%locale%"

jms_i18n_routing:
	default_locale: %locale%
	locales: %locales%
	strategy: prefix

La stratégie "prefix" va ajouter au route un préfix de langue, dans ce mode la locale par défaut sera l'anglais. Si on veut obtenir par défaut la langue de son choix, on va adopter la stratégie "prefix_except_default".

Ajout des locales dans le fichier parameters

nano app/config/parameters.yml


locale: fr
locales:
    	- en
    	- fr

nano app/config/parameters.yml.dist


locale: fr
locales: [fr, en]		

1.3 - Ajout de préfixes de langues dans les routes de login

Ajout de préfixes de langues pour l'accès au backoffice, si on se trouve dans le contexte d'utilisation avec FOSUserBundle.

nano app/config/security.yml

Modifier comme suit :


security:
    
    firewalls:

        # login area for backoffice users
        backoffice:
            context: primary_auth
            pattern:            ^/(fr)|(en)/backoffice
            form_login:
                provider:       fos_userbundle
                login_path:     bundle_name_back_office_security_login
                use_forward:    true
                use_referer:    true
                check_path:     bundle_name_back_office_security_check
                #failure_path:   null
                default_target_path: bundlename_backofficebundle_home
            logout:
                path: bundle_name_back_office_security_logout
                target: bundle_name_cms_homepage
            anonymous:    true

        # defaut login area for standard users
        main:
            context: primary_auth
            pattern:            ^/(fr)|(en)/
            form_login:
                provider:       fos_userbundle
                login_path:     fos_user_security_login
                use_forward:    true
                use_referer:    true
                check_path:     fos_user_security_check
                #failure_path:   null
                default_target_path: fos_user_profile_show
            logout:
                path: fos_user_security_logout
                target: bundle_name_cms_homepage
            anonymous:    true

    access_control:
        # Routes are prefixed by ther user locale.
        - { path: ^/[^/]+/login$, role: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/[^/]+/register, role: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/[^/]+/resetting, role: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/[^/]+/backoffice, role: ROLE_ADMIN }
        - { path: ^/[^/]+/, role: IS_AUTHENTICATED_ANONYMOUSLY }



1.4 Traduction des routes

On va créer des fichiers de traductions des routes de façon à pouvoir traduire les préfixes statiques de ces dernières :


mkdir app/Resources/translations
php app/console translation:extract fr en --enable-extractor=jms_i18n_routing --dir="./bin" --output-dir="./app/Resources/translations" --output-format="yml"

On va obtenir deux fichiers de traductions dans app/Resources/translations :

  • routes.fr.yml
  • routes.en.yml

On va pouvoir personnaliser les routes dans ces fichiers :

nano app/Resources/translations/routes.fr.yml


appname_mainbundle_article_index: '/article/{slug}'

nano app/Resources/translations/routes.en.yml


appname_mainbundle_article_index: '/article-en/{slug}'

2 - Mise en place des traductions statiques

2.1 - Ajout des fichiers de traduction statiques

Création des fichiers suivants:


mkdir app/Resources/translations
touch app/Resources/translations/forms.en.yml
touch app/Resources/translations/forms.fr.yml
touch app/Resources/translations/FOSUserBundle.en.yml
touch app/Resources/translations/FOSUserBundle.fr.yml
touch app/Resources/translations/messages.en.yml
touch app/Resources/translations/messages.fr.yml

Les fichiers "forms" vont gérér les champs de formulaires, "messages" vont centraliser le reste de contenus statiques et FOSUserBundle va surcharger les traductions du bundle.

Exemple pour le fichier messages.fr.yml :


## COMMUNE ELEMENTS ##

home: "Accueil"
title: "Titre"
description: "Description"
content: "Contenu"

## BACKOFFICE ##

# LOGIN
email_address: "Adresse email"
password: "Mot de passe"

# ERRORS

error:
    404:
        title: Error 404
        description: "Désolé, la page demandée n'existe pas."

mandatory_fields_advice: "Tous les champs avec * sont obligatoires."

2.2 - Utilisation des traductions statiques dans les vues

On va appeler nos traductions statiques dans nos vues en ajoutant {{ 'cle_trad'|trans }} pour appeler la chaine à traduire. 'cle_trad" représente la clé de traduction présente dans le fichier de traduction yml

Pour les labels des formualaires on passera la clé de traduction dans le champs de tableau 'label'.

3 - Traduction des contenus en base de données

Chaque champs de base de données qui va nécessiter une traduction sera stocké dans une table externe contenant ces dernière. On va donc créer des tables de traduction pour chacunes des entités du model le nécessitant.

Exemple pour une table d'un article de blog :

On dispose d'une table article comportant les champs suivants :

table article :

  • - id
  • - title
  • - description
  • - content

On souhaite avoir des traductions pour les champs : title, description et content.

On va supprimer ces champs de la table article et on va créer une table article_translation qui va disposer des champs suivants :

table article_translation :

  • - id
  • - article_id
  • - locale
  • - title
  • - description
  • - content

La table article_translation sera propriétaire de la clé étrangère de la table article.

3.1 - Création d'une entité de traduction

Dans Doctrine on ne va plus raison en tables mais en objets, on va donc créer une entités de traduction et modifier l'entité Article nécessitant des champs traduits :

Étapes :

  • - Création d'un entité ArticleTranslation
  • - Ajout des relations entre Article et ArticleTranslation

Si on se trouve dans le cas d'une application existante avec du contenu en base, il faudra prendre le soin de migrer les données avant de supprimer les propriétés disposant d'une traduction de la class Entité (Article dans le cas présent).

3.2 - Création l'entité de traduction ArticleTranslation :

nano src/AppName/NameBundle/Entity/ArticleTranslation.php



<?php

namespace AppName\NameBundle\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
 * ArticleTranslation
 *
 * @ORM\Table(name="article_translation")
 * @ORM\Entity()
 */
class ArticleTranslation
{
    /**
     * @var integer
     *
     * @ORM\Column(name="id", type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    private $id;
    
    /**
     * @var string
     *
     * @ORM\Column(name="locale", type="string", length=5)
     */
    private $locale;
    
    /**
     * @var string
     *
     * @ORM\Column(name="title", type="string", length=255)
     */
    private $title;

    /**
     * @var string
     *
     * @ORM\Column(name="description", type="text", nullable=true)
     */
    private $description;

    /**
     * @var string
     *
     * @ORM\Column(name="content", type="text", nullable=true)
     */
    private $content;

    /**
     * @ORM\ManyToOne(targetEntity="AppName\NameBundle\Entity\Article", inversedBy="articleTranslations", cascade="persist")
     * @ORM\JoinColumn(nullable=false, onDelete="cascade")
     */
    private $article;

    /**
     * @return int
     */
    public function getId()
    {
        return $this->id;
    }

    /**
     * @param int $id
     */
    public function setId($id)
    {
        $this->id = $id;
    }

    /**
     * @return string
     */
    public function getLocale()
    {
        return $this->locale;
    }

    /**
     * @param string $locale
     */
    public function setLocale($locale)
    {
        $this->locale = $locale;
    }

    /**
     * @return mixed
     */
    public function getArticle()
    {
        return $this->article;
    }

    /**
     * @param mixed $article
     */
    public function setArticle($article)
    {
        $this->article = $article;
    }

    /**
     * @return string
     */
    public function getTitle()
    {
        return $this->title;
    }

    /**
     * @param string $title
     */
    public function setTitle($title)
    {
        $this->title = $title;
    }

    /**
     * @return string
     */
    public function getDescription()
    {
        return $this->description;
    }

    /**
     * @param string $description
     */
    public function setDescription($description)
    {
        $this->description = $description;
    }

    /**
     * @return string
     */
    public function getContent()
    {
        return $this->content;
    }

    /**
     * @param string $content
     */
    public function setContent($content)
    {
        $this->content = $content;
    }

}

3.3 - Modification de l'entité Article et ajout de la relation inverse

nano src/AppName/NameBundle/Entity/Article.php



/**
 * Constructor
 */
public function __construct($locales = array())
{
	$this->rowBills = new ArrayCollection();
	 $this->itemTranslations = new ArrayCollection();

	foreach($locales as $locale)
	 {
		$itemTranslation = new ItemTranslation();
		$itemTranslation->setLocale($locale);
		$this->addItemTranslation($itemTranslation);
	}
}

/**
 * @var
 *
 * @ORM\OneToMany(targetEntity="AppName\NameBundle\Entity\ArticleTranslation", mappedBy="article", cascade="persist", indexBy="locale")
 * @ORM\JoinColumn(nullable=true, onDelete="cascade")
 */
private $articleTranslations;

/**
 * @param $articleTranslation
 * @return $this
 */
public function addArticleTranslation($articleTranslation)
{
    $this->articleTranslations[] = $articleTranslation;
    $articleTranslation->setArticle($this);

    return $this;
}

/**
 * @param $articleTranslations
 */
public function setArticleTranslations($articleTranslations)
{
    $this->articleTranslations->clear();
    foreach ($articleTranslations as $articleTranslation) {
        $this->addArticleTranslation($articleTranslation);
    }
}

/**
 * @return ArrayCollection
 */
public function getArticleTranslations()
{
    return $this->articleTranslations;
}

On peut se retrouver dans le cas où les traductions liées ne se chargent pas dans la vue mais sont quand même accessibles dans le controller. Pour parer à ce problème de Lazy loading, on ajout la propriété "fetch" settée avec la valeur "EAGER" sur la propriété ArticleTranslations de l'entité Article :

nano src/AppName/NameBundle/Entity/Article.php


   /**
     * @var
     *
     * @ORM\OneToMany(targetEntity="AppName\NameBundle\Entity\ArticleTranslation", mappedBy="article", cascade="persist", indexBy="locale", fetch="EAGER")
     * @ORM\JoinColumn(nullable=true, onDelete="cascade")
     */
    private $articleTranslations;

3.4 - Mise à jour du schéma de la base de donnée

php app/console doctrine:schema:update --force

3.5 - Affichages des traductions dans les vues

On va vouloir afficher les données de notre entité multilingue dans une vue via le paramètre de langue de l'url.

Nous allons voir comment nous allons afficher nos traductions dans une vue.

Création d'un extension twig qui va contenir la méthode translate() qu'on va appeler depuis la vue.

nano src/AppName/NameBundle/Twig/TranslateExtension.php


< ?php
namespace AppName\NameBundle\Twig;

use Symfony\Component\PropertyAccess\PropertyAccess;
use Symfony\Component\HttpFoundation\RequestStack;

class TranslateExtension extends \Twig_Extension
{
    /**
     *
     * @var string
     */
    protected $locale;
    
    /**
     *
     * @var string
     */
    protected $defaultLocale;
    
    /**
     *
     * @var \Symfony\Component\PropertyAccess\PropertyAccessor
     */
    protected $propertyAccessor;
    
    public function __construct(RequestStack $requestStack)
    {
        if($requestStack->getMasterRequest())
        {
            $this->locale = $requestStack->getMasterRequest()->getLocale();
            $this->defaultLocale = $requestStack->getMasterRequest()->getDefaultLocale();
        }
        
        $this->propertyAccessor = PropertyAccess::createPropertyAccessor();
    }

    public function getFunctions()
    {
        return array(
            new \Twig_SimpleFunction('translate', array($this, 'translate')),
        );
    }

    public function translate($array, $key)
    {
        if(!is_array($array) && method_exists($array, 'toArray'))
        {
            $array = $array->toArray();
        }
        elseif(!is_array($array))
        {
            throw new \Exception('Translation need an array or an object wich implements "toArray"');
        }
        if(isset($array[$this->locale]))
        {
            return $this->propertyAccessor->getValue($array[$this->locale], $key);
        }
        if(isset($array[$this->defaultLocale]))
        {
            return $this->propertyAccessor->getValue($array[$this->defaultLocale], $key);
        }
        else
        {
            throw new \Exception('Object key "'.$key.'" not translated');
        }
    }


    public function getName()
    {
        return 'translate';
    }
}

Enregistrement de l'extension Twig en tant que service :

nano src/AppName/NameBundle/Resources/config/services.yml


imports:
	- { resource: services/twig.yml }
	

nano src/AppName/NameBundle/Resources/config/services/twig.yml


services:
    economat.main.twig.translate_extension:
        class: AppName\NameBundle\Twig\TranslateExtension
        arguments: ["@request_stack"]
        tags:
            - { name : twig.extension }
	

On peut maintenant appeler une traduction dans une vue en passant par la balise la fonction translate : {{ translate(article.articleTranslations, 'title') }}

3.6 - Ajout du formulaire disposant des traductions

On va adapter les formulaires à la nouvelle architecture du modèle :

- On va éditer le formulaire de ArticleType pour lui ajouter les propriétés : locale et defaultLocale. Puis on va supprimer les champs faisant l'objet d'une traduction :


<?php

namespace AppName\NameBundle\Form\Type;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolverInterface;
use Symfony\Component\HttpFoundation\Request;
use Doctrine\ORM\EntityManager;

class ArticleType extends AbstractType
{
    /**
     * @var EntityManager
     */
    private $entityManager;

    /**
     * @var string
     */
    private $locale;

    /**
     * @var string
     */
    private $defaultLocale;

    public function __construct(EntityManager $entityManager, Request $request)
    {
        $this->entityManager = $entityManager;
        $this->locale = $request->getLocale();
        $this->defaultLocale = $request->getDefaultLocale();
    }

    /**
     * @param FormBuilderInterface $builder
     * @param array $options
     */
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('articleTranslations', 'collection', array(
                'type' => new ArticleTranslationType(),
                'allow_add' => false,
                'allow_delete' => false,
            ))
        ;
    }
    
    /**
     * @param OptionsResolverInterface $resolver
     */
    public function setDefaultOptions(OptionsResolverInterface $resolver)
    {
        $resolver->setDefaults(array(
            'data_class' => 'AppName\NameBundle\Entity\Article'
        ));
    }

    /**
     * @return string
     */
    public function getName()
    {
        return 'appname_namebundle_article';
    }
}


- Création du formulaire ArticleTranslationType avec les champs traduits :

nano src/AppName/NameBundle/Form/ArticleTranslationType.php


<?php

namespace AppName\NameBundle\Form\Type;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolverInterface;

/**
* Class ArticleTranslationType
* @package AppName\NameBundle\Form\Type
*/
class ArticleTranslationType extends AbstractType
{
	/**
	 * @param FormBuilderInterface $builder
	 * @param array $options
	 */
	public function buildForm(FormBuilderInterface $builder, array $options)
	{
	    $builder
	        ->add('title', 'text', array(
	            'label' => 'title'
	        ))
	        ->add('description', 'textarea', array(
	            'required' => false,
	            'label' => 'title'
	        ))
	        ->add('content', 'textarea', array(
	            'required' => false,
	            'label' => 'content'
	        ))
	    ;
	}

	/**
	 * @param OptionsResolverInterface $resolver
	 */
	public function setDefaultOptions(OptionsResolverInterface $resolver)
	{
	    $resolver->setDefaults(array(
	        'data_class' => 'AppName\NameBundle\Entity\ArticleTranslation'
	    ));
	}

	/**
	 * @return string
	 */
	public function getName()
	{
	    return 'appname_namebundle_article_translation';
	}
}
	

3.7 Modification des Controller/Vue pour la création d'articles multilingues

3.7.1 - Modification de la méthode createAction() du controller ArticleController

nano src/AppName/NameBundle/Controller/ArticleController.php


public function createAction(Request $request)
{
	// call entity manager
	$em = $this->getDoctrine()->getManager();

	// create new article and form
	$article = new Item($this->container->getParameter('locales'));
	$form = $this->createForm(new ArticleType($em, $request), $article);
	$form->add('submit', 'submit', array(
		'label' => 'save',
		'attr' => array(
    			'class' => 'btn btn-success'
		)
	));

	if ($request->getMethod() == 'POST') {
		// handle form
		$form->handleRequest($request);

		// check if form is valid and post sent
		if ($form->isValid()) {

			// save
			$em->persist($item);
			$em->flush();

			return $this->redirect($this->generateUrl('economat_backoffice_bundle_item_index'));
		}
	}

	return $this->render('EconomatBackOfficeBundle:Item:create.html.twig', array(
		'item' => $item,
		'form'   => $form->createView(),
	));
}

3.7.2 - Modification de la vue create

nano src/AppName/NameBundle/Resources/views/Article/create.html.twig


{% extends 'AppNameNameBundle::layout.html.twig' %}

{% block content %}

    {% form_theme form 'bootstrap_3_layout.html.twig' %}
    {{ form_start(form, {attr: {novalidate: 'novalidate'}}) }}

    <div class="translations">
        <ul class="nav nav-tabs">
            {% for translation in form.postBlogTranslations %}
                <li class="{{ loop.first ? 'active' : '' }}">
                    <a href="#translation-{{ translation.vars.value.locale }}" role="tab" data-toggle="tab">
                        {{ translation.vars.value.locale }}
                    </a>
                </li>
            {% endfor %}
        </ul>
        <div class="tab-content">
            {% for translation in form.articleTranslations %}
                <div id="translation-{{ translation.vars.value.locale }}" class="tab-pane{{ loop.first ? ' active' : '' }}">
                    {% for element in translation %}
                        {{ form_row(element) }}
                    {% endfor %}
                </div>
            {% endfor %}
        </div>
    </div>

    {{ form_end(form) }}
    

{% endblock %}


3.7.3 - Modification de la méthode editAction() du controller ArticleController

nano src/AppName/NameBundle/Controller/ArticleController.php


public function editAction($id, Request $request)
{
    // call entity manager
    $em = $this->getDoctrine()->getManager();

    // retrieve article
    $article = $em->getRepository('AppNameNameBundle:Article')->find($id);

    // check if article exists
    if (!$article) {
        throw $this->createNotFoundException('Unable to find article entity.');
    }

    // create form
    $editForm = $this->createForm(new ArticleType($em, $request), $article);
    $editForm->add('submit', 'submit', array(
        'label' => 'Update',
        'attr' => array(
            'class' => 'btn btn-success'
        )
    ));

  if ($request->getMethod() == 'POST') {
	// handle request
    	$editForm->handleRequest($request);

    	// check if form is valid and post sent
    	if ($editForm->isValid()) {

        	// save
        	$em->persist($article);
        	$em->flush();

        	// send message
        	$this->get('session')->getFlashBag()->add('info', 'article_saved');

        	// redirect
        	return $this->redirect($this->generateUrl('appname_namebundle_article'));
    	}
  }

    return $this->render('AppNameNameBundle:Article:edit.html.twig', array(
        'article' => $article,
        'edit_form' => $editForm->createView()
    ));
}

3.7.4 - Modification de la vue edit

nano src/AppName/NameBundle/Resources/views/Article/edit.html.twig


{% extends 'AppNameNameBundle::layout.html.twig' %}

{% block content %}

    
    {% form_theme edit_form 'bootstrap_3_layout.html.twig' %}
    {{ form_start(edit_form, {attr: {novalidate: 'novalidate'}}) }}

    <div class="translations">
        <ul class="nav nav-tabs">
            {% for translation in edit_form.articleTranslations %}
                <li class="{{ loop.first ? 'active' : '' }}">
                    <a href="#translation-{{ translation.vars.value.locale }}" role="tab" data-toggle="tab">
                        {{ translation.vars.value.locale }}
                    </a>
                </li>
            {% endfor %}
        </ul>
        <div class="tab-content">
            {% for translation in edit_form.articleTranslations %}
                <div id="translation-{{ translation.vars.value.locale }}" class="tab-pane{{ loop.first ? ' active' : '' }}">
                    {% for element in translation %}
                        {{ form_row(element) }}
                    {% endfor %}
                </div>
            {% endfor %}
        </div>
    </div>
    
    {{ form_end(edit_form) }}
    

{% endblock %}


Ajout de CSS pour les blocs des champs de formulaires avec traduction :

nano src/AppName/NameBundle/Resources/public/css/back-office.css


/* Translation bloc */

.translations .tab-content
{
    padding-top: 20px;
}

.translations
{
    margin-bottom: 20px;
}

.translations label
{
    font-weight: bold;
}

.translations .tab-content
{
    border: 1px solid #ccc;
    border-top: none;
    padding: 20px 20px 10px;
}

Le projet dispose d'une entité Article multilingue sur les propriétés suivantes : title, description et content.

Ressources

http://jmsyst.com/bundles/JMSI18nRoutingBundle