Manage multiple translations with Doctrine Gedmo Translatable (English)

2013-08-20

  symfony    doctrine    english    internationalization 

When you have to manage multiple translations in your entities using Symfony2 / Doctrine, the Doctrine Gedmo Translatable appears quickly as the best solution because it offer a simply & efficiency translation management.

I will try to explain you how to use this extension.

Installation

Start by adding the following package in your composer.json file:


"require": {
    ...
    "gedmo/doctrine-extensions": "dev-master"
}

Next, run php composer.phar update gedmo/doctrine-extensions to complete bundle vendor installation.

Configuration

Edit your app/config/config.yml file to add the translatable listener:


services:
    # Doctrine Extension listeners to handle behaviors
    gedmo.listener.translatable:
        class: Gedmo\\Translatable\\TranslatableListener
        tags:
            - { name: doctrine.event_subscriber, connection: default }
        calls:
            - [ setAnnotationReader, [ @annotation_reader ] ]
            - [ setDefaultLocale, [ en ] ]
            - [ setTranslationFallback, [ true ] ]

Configuration is now complete. We will now try to write a structure to manage your entities translations.

Entities modifications

In order to add "translatable" fields, you must define some mapping properties (we will use annotations here) on your entities fields.

For instance, let's take a Post entity in which we want to translate the title field:


namespace Eko\\MyBundle\\Entity;

use Doctrine\\ORM\\Mapping as ORM;

use Gedmo\\Mapping\\Annotation as Gedmo;
use Gedmo\\Translatable\\Translatable;

/**
 * Class Post
 *
 * @ORM\\Entity
 * @ORM\\HasLifecycleCallbacks
 * @ORM\\Table(name="post")
 * @ORM\\Entity(repositoryClass="Eko\\MyBundle\\Repository\\PostRepository")
 *
 * @Gedmo\\TranslationEntity(class="Eko\\MyBundle\\Entity\\Translation\\PostTranslation")
 */
class Post implements Translatable {
    /**
     * Post title
     *
     * @Gedmo\\Translatable
     * @ORM\\Column(type="text", length=250, nullable=true)
     */
    protected $title;

    /**
     * Post locale
     * Used locale to override Translation listener's locale
     *
     * @Gedmo\\Locale
     */
    protected $locale;

    /**
     * Sets post title
     *
     * @param string $title
     */
    public function setTitle($title)
    {
        $this->title = $title;
    }
    
    /**
     * Returns post title
     *
     * @return string
     */
    public function getTitle()
    {
        return $this->title;
    }

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

As you can see, we defines a TranslationEntity annotation to define a special entity which will be use to store our translations for this entity only. Yes, we can have translations stored in different tables instead of having all translations of all entities in the same database table. Certainly better for performance with many translations.

Let's create our translation entity:


namespace Eko\\MyBundle\\Entity\\Translation;

use Doctrine\\ORM\\Mapping as ORM;
use Gedmo\\Translatable\\Entity\\MappedSuperclass\\AbstractTranslation;

/**
 * @ORM\\Table(name="ext_translations_post", indexes={
 *      @ORM\\Index(name="post_translation_idx", columns={"locale", "object_class", "field", "foreign_key"})
 * })
 * @ORM\\Entity(repositoryClass="Gedmo\\Translatable\\Entity\\Repository\\TranslationRepository")
 */
class PostTranslation extends AbstractTranslation
{

}

You now have your entities ready to start managing multiple translations that will be stored in your database. Don't forget to update your database schema.

How to retrieve our translated fields in our repository?

To retrieve your fields translated in a given locale, I have written a little (but so cool) TranslatableRepository.

This repository will add for you a "hint" to your repository queries. You just have to extend this repository by all your repositories that are using translations. Take a look:


namespace Eko\\MyBundle\\Repository;

use Doctrine\\ORM\\EntityRepository;
use Doctrine\\ORM\\QueryBuilder;
use Doctrine\\ORM\\AbstractQuery;
use Doctrine\\ORM\\Query;

use Gedmo\\Translatable\\TranslatableListener;

/**
 * Class TranslatableRepository
 *
 * This is my translatable repository that offers methods to retrieve results with translations
 */
class TranslatableRepository extends EntityRepository
{
    /**
     * @var string Default locale
     */
    protected $defaultLocale;

    /**
     * Sets default locale
     *
     * @param string $locale
     */
    public function setDefaultLocale($locale)
    {
        $this->defaultLocale = $locale;
    }

    /**
     * Returns translated one (or null if not found) result for given locale
     *
     * @param QueryBuilder $qb            A Doctrine query builder instance
     * @param string       $locale        A locale name
     * @param string       $hydrationMode A Doctrine results hydration mode
     *
     * @return QueryBuilder
     */
    public function getOneOrNullResult(QueryBuilder $qb, $locale = null, $hydrationMode = null)
    {
        return $this->getTranslatedQuery($qb, $locale)->getOneOrNullResult($hydrationMode);
    }

    /**
     * Returns translated results for given locale
     *
     * @param QueryBuilder $qb            A Doctrine query builder instance
     * @param string       $locale        A locale name
     * @param string       $hydrationMode A Doctrine results hydration mode
     *
     * @return QueryBuilder
     */
    public function getResult(QueryBuilder $qb, $locale = null, $hydrationMode = AbstractQuery::HYDRATE_OBJECT)
    {
        return $this->getTranslatedQuery($qb, $locale)->getResult($hydrationMode);
    }

    /**
     * Returns translated array results for given locale
     *
     * @param QueryBuilder $qb     A Doctrine query builder instance
     * @param string       $locale A locale name
     *
     * @return QueryBuilder
     */
    public function getArrayResult(QueryBuilder $qb, $locale = null)
    {
        return $this->getTranslatedQuery($qb, $locale)->getArrayResult();
    }

    /**
     * Returns translated single result for given locale
     *
     * @param QueryBuilder $qb            A Doctrine query builder instance
     * @param string       $locale        A locale name
     * @param string       $hydrationMode A Doctrine results hydration mode
     *
     * @return QueryBuilder
     */
    public function getSingleResult(QueryBuilder $qb, $locale = null, $hydrationMode = null)
    {
        return $this->getTranslatedQuery($qb, $locale)->getSingleResult($hydrationMode);
    }

    /**
     * Returns translated scalar result for given locale
     *
     * @param QueryBuilder $qb     A Doctrine query builder instance
     * @param string       $locale A locale name
     *
     * @return QueryBuilder
     */
    public function getScalarResult(QueryBuilder $qb, $locale = null)
    {
        return $this->getTranslatedQuery($qb, $locale)->getScalarResult();
    }

    /**
     * Returns translated single scalar result for given locale
     *
     * @param QueryBuilder $qb     A Doctrine query builder instance
     * @param string       $locale A locale name
     *
     * @return QueryBuilder
     */
    public function getSingleScalarResult(QueryBuilder $qb, $locale = null)
    {
        return $this->getTranslatedQuery($qb, $locale)->getSingleScalarResult();
    }

    /**
     * Returns translated Doctrine query instance
     *
     * @param QueryBuilder $qb     A Doctrine query builder instance
     * @param string       $locale A locale name
     *
     * @return Query
     */
    protected function getTranslatedQuery(QueryBuilder $qb, $locale = null)
    {
        $locale = null === $locale ? $this->defaultLocale : $locale;

        $query = $qb->getQuery();

        $query->setHint(
            Query::HINT_CUSTOM_OUTPUT_WALKER,
            'Gedmo\\\\Translatable\\\\Query\\\\TreeWalker\\\\TranslationWalker'
        );

        $query->setHint(TranslatableListener::HINT_TRANSLATABLE_LOCALE, $locale);

        return $query;
    }
}

Now, let's extend this repository by yours:


namespace Eko\\MyBundle\\Repository;

use Doctrine\\ORM\\EntityRepository;
use Doctrine\\ORM\\QueryBuilder;

use Eko\\MyBundle\\Repository\\TranslatableRepository;

/**
 * Class PostRepository
 *
 * This is the Post entity repository class
 */
class PostRepository extends TranslatableRepository
{

The goal is to stop calling the getResult() method from your Doctrine query object retrieved by the QueryBuilder. You just have to call $this->getResult($qb) by giving it the QueryBuilder object and the TranslatableRepository will alter the query.

To be clear, here is a simple use case:


    /**
     * Returns all posts
     *
     * @return array
     */
    public function findAll()
    {
        $qb = $this->createQueryBuilder('post');

        return $this->getResult($qb, 'fr');
    }

In this example, we are explicitly asking for french locale translations.

Automatically retrieve the current request locale

To go further, we also define a TranslatableManager which will be extended by our managers in order to set the request locale in the du TranslatableRepository.

Here is our TranslatableManager :


namespace Eko\\MyBundle\\Manager;

use Symfony\\Component\\DependencyInjection\\ContainerInterface;

use Doctrine\\ORM\\EntityManager;

/**
 * Translatable entity manager
 */
class TranslatableManager
{
    /**
     * @var \\Doctrine\\ORM\\EntityManager
     */
    protected $em;

    /**
     * @var EntityRepository
     */
    protected $repository;

    /**
     * @var string Class name
     */
    protected $class;


    /**
     * Constructor
     *
     * @param EntityManager $em    Entity manager
     * @param string        $class Class name
     */
    public function __construct(EntityManager $em, $class)
    {
        $this->class      = $class;
        $this->em         = $em;
        $this->repository = $em->getRepository($this->class);
    }

    /**
     * Sets the repository request default locale
     *
     * @param ContainerInterface|null $container
     * 
     * @throws \\InvalidArgumentException if repository is not an instance of TranslatableRepository
     */
    public function setRepositoryLocale($container)
    {
        if (null !== $container) {
            if (!$this->repository instanceof TranslatableRepository) {
                throw new \\InvalidArgumentException('A TranslatableManager needs to be linked with a TranslatableRepository to sets default locale.');
            }

            if ($container->isScopeActive('request')) {
                $locale = $container->get('request')->getLocale();
                $this->repository->setDefaultLocale($locale);
            }
        }
    }
}

Let's extend our managers with the TranslatableManager:


namespace Eko\\MyBundle\\Manager;

use Eko\\MyBundle\\Manager\\TranslatableManager;

/**
 * Post entity manager
 */
class PostManager extends TranslatableManager
{

That's it, it only remains to call the setRepositoryLocale method during the manager initialization, as it's done below:


<service id="eko.mybundle.manager.post" class="Eko\\MyBundle\\Manager\\PostManager" public="true">
    <argument type="service" id="doctrine.orm.default_entity_manager" />
    <argument>Eko\\MyBundle\\Entity\\Post</argument>

    <call method="setRepositoryLocale"><argument type="service" id="service_container" /></call>
</service>

The end!

You are know almost mastering multiple translations in your entities using the Doctrine Gedmo Translatable extension.

→ A quick link to the Gedmo Translatable documentation: https://github.com/l3pp4rd/DoctrineExtensions/blob/master/doc/translatable.md

Comments