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