Multilingual Symfony API

Bruno Krnetic
Q agency
Published in
9 min readAug 30, 2019

There is a latin proverb:

“Quot linguas calles, tot homines vales, meaning — As Many languages you know, as many times you are a human being…”

Well, we code using languages, so I reckon it’s fair enough to apply the proverb in IT world as well. I dare to say there is still no 100% reliable translation service out there, so allowing users to dynamically define languages and corresponding data sounds pretty catchy, doesn’t it?

So without any further ado, let’s get onto the topic. For implementation of this multilingual feature we will use Knp Doctrine2 Behaviors bundle based on a collection of traits and interfaces that add behaviors to Doctrine 2 entities and repositories. It offers a lot for a bundle, but what we need this time is just translatable behavior.

Official documentation in the given link is enough to get you started, but there are still some additional key requirements and tricks you need to make this whole thing work. In this article, we will go through the steps of implementation and show how this feature works using the basic CRUD functionalities realized as REST API.

Things that are not covered in this article but you should be familiar with are:

  • Basic OOP principles
  • PHP 7.1
  • Symfony 4.2.4
  • Controller, Traits
  • Serializer
  • Docker

First let’s install the Knp Doctrine 2 Behaviors bundle and go through the setup steps. Enter the the project directory and enter the following command:

composer require knplabs/doctrine-behaviors:~1.1

Since we use only translatable feature, we can disable other features / subscribers by adding the following code in our config.yml file:

knp_doctrine_behaviors:
blameable: false
geocodable: ~ # Here null is converted to false
loggable: ~
sluggable: false
soft_deletable: false
translatable: true
# All others behaviors are disabled```

If this is omitted, all subscribers will be enabled. After defining which subscribers will be enabled and which will not, we need to register them. Depending on a version of Symfony, it can be done in several ways.

If using Symfony 2 or 3, you can edit registerBundles() method in AppKernel class and add the following to the $bundles array:

class AppKernel
{
function registerBundles()
{
$bundles = array(
//...
new Knp\DoctrineBehaviors\Bundle\DoctrineBehaviorsBundle(),
//...
);
//...return $bundles;
}
}

or you can do it the deprecated way by adding the following to your config.yml file:

# app/config/config.yml
imports:
- { resource: ../../vendor/knplabs/doctrine-behaviors/config/orm-services.yml }

The third and the last way to do the mentioned is to add subscriber(s) using the EventManager:

$em->getEventManager()->addEventSubscriber(new \Knp\DoctrineBehaviors\ORM\Translatable\TranslatableSubscriber);

In Symfony 4+ you will just need to update your bundles.php file and add something like this in the returning array:

return = [
//...
Knp\DoctrineBehaviors\Bundle\DoctrineBehaviorsBundle::class => ['all' => true],
//...
]

Once we are set up, we can start the implementation. Let’s create two classes Movie and MovieTranslation.

<?phpnamespace App\Entity;use App\TranslationBundle\Translation\TranslationInterface;
use DateTime;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
use App\TranslationBundle\Translation\TranslatableTrait;
use Symfony\Component\Serializer\Annotation\SerializedName;
/**
* Movie
*
* @ORM\Table(name="movie")
* @ORM\Entity(repositoryClass="App\Repository\MovieRepository")
*/
class Movie implements EntityInterface, TranslationInterface
{
use TranslatableTrait;
/**
* @var int
*
* @ORM\Column(name="id", type="integer")
* @ORM\Id
* @ORM\GeneratedValue(strategy="AUTO")
* @Groups({"movie:id"})
*/
private $id;
/**
* @var DateTime
*
* @ORM\Column(name="release_date", type="date")
* @Groups({"movie:default"})
*/
private $releaseDate;
/**
* @return int
*/
public function getId(): int
{
return $this->id;
}
/**
* @return DateTime
*/
public function getReleaseDate(): DateTime
{
return $this->releaseDate;
}
/**
* @param DateTime $releaseDate
*
* @return Movie
*/
public function setReleaseDate(DateTime $releaseDate): Movie
{
$this->releaseDate = $releaseDate;
return $this;
}
}
<?phpnamespace App\Entity\Translation;use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
use App\TranslationBundle\Translation\TranslationTrait;
/**
* @ORM\Entity()
*/
class MovieTranslation
{
use TranslationTrait;
/**
* @var string
*
* @ORM\Column(name="title", type="string", length=255)
* @Groups({"movie:default"})
*/
private $title;
/**
* @var string
*
* @ORM\Column(name="description", type="text")
* @Groups({"movie:default"})
*/
private $description;
/**
* @return string
*/
public function getTitle(): string
{
return $this->title;
}
/**
* @param string $title
*
* @return MovieTranslation
*/
public function setTitle(string $title): MovieTranslation
{
$this->title = $title;
return $this;
}
/**
* @return string
*/
public function getDescription(): string
{
return $this->description;
}
/**
* @param string $description
*
* @return MovieTranslation
*/
public function setDescription(string $description): MovieTranslation
{
$this->description = $description;
return $this;
}
}

By default, translatable class Movie and translation class MovieTranslation should be within the same namespace. When working on bigger projects, that could result in a messy project structure. Luckily, there is a way to use separate namespace and to define different class name for translation classes. To do that, we need to override Translation and Translatable traits and their methods getTranslationEntityClass() and getTranslatableEntityClass().

To keep the project structure even more organized, let’s store our custom traits TranslationTrait and TranslatableTrait under newly created App\TranslationBundle\Translation namespace where we will keep everything we need for a proper implementation of Doctrine2 translatable behavior.

<?phpnamespace App\TranslationBundle\Translation;use Knp\DoctrineBehaviors\Model\Translatable\Translatable;
use Symfony\Component\PropertyAccess\PropertyAccess;
trait TranslatableTrait
{
use Translatable;
public static function getTranslationEntityClass()
{
$explodedNamespace = explode('\\', __CLASS__);
$entityClass = array_pop($explodedNamespace);
return '\\'.implode('\\', $explodedNamespace).'\\Translation\\'.$entityClass.'Translation';
}
}
<?phpnamespace App\TranslationBundle\Translation;use Knp\DoctrineBehaviors\Model\Translatable\Translation;trait TranslationTrait
{
use Translation;
/**
* @inheritdoc
*/
public static function getTranslatableEntityClass()
{
$explodedNamespace = explode('\\', __CLASS__);
$entityClass = array_pop($explodedNamespace);
// Remove Translation namespace
array_pop($explodedNamespace);
return '\\'.implode('\\', $explodedNamespace).'\\'.substr($entityClass, 0, -11);
}
}

The last step is overriding trait parameters of DoctrineBehaviors inside your parameters.yml or doctrine.yml file:

parameters:
knp.doctrine_behaviors.translatable_subscriber.translatable_trait: App\TranslationBundle\Translation\TranslatableTrait
knp.doctrine_behaviors.translatable_subscriber.translation_trait: App\TranslationBundle\Translation\TranslationTrait

Now, we can create sub namespace App\Entity\Translation where we will store all corresponding translation classes, in this case — class MovieTranslation.

After we’ve done this and made sure that class Movie uses TranslatableTrait and MovieTranslation class uses TranslationTrait, we can upgrade our doctrine schema using the command:

bin/console doctrine:schema:update --force

Bam! We are ready to do the fun stuff. There are few of the key methods you will find useful during the implementation of translatable behavior.

These are:

  • translate($locale = null, $fallbackToDefault = true)
  • getTranslations()
  • mergeNewTranslations()

If you have only one entity with translatable properties, you can do it the simple way as it is stated in documentation:

<?php$movie = new Movie;
$movie->translate('en')->setTitle('Avengers');
$movie->translate('fr')->setTitle('Vengeurs');
$em->persist($movie);// In order to persist new translations, call mergeNewTranslations method, before flush$movie->mergeNewTranslations();
$movie->flush();
// To get translations in specific language (e.g. French), use the following
$movie->translate('fr')->getTitle();

To get translations, you can either use getTranslations method to get all the translations (all languages) or translate(‘en’)→getTitle() to get translation in given language, in this example, English.

Since our goal here is to allow users to define languages dynamically, we need to optimize this a little bit further. Speaking of it, we can create service TranslatonService under App\Service namespace and TranslationInterface under App\TranslationBundle\Translation namespace. Each translatable class (e.g. Movie) should implement 2 key methods of TranslationInterface.

<?phpnamespace App\TranslationBundle\Translation;interface TranslationInterface
{
public function getTranslation();
public function setTranslatableProperties($lang, $value);
}

The method setTranslatableProperties($lang, $value) is used to set translatable fields’ values for given language. Depending on number of translatable properties, the method can be less or more complex. However, in this example we have only 2 translatable properties title and description.

public function setTranslatableProperties($lang, $value)
{
$this->translate($lang, false)->setTitle($value->title);
$this->translate($lang, false)->setDescription($value->description);
}

The second method getTranslation is custom method used to get translations in all languages. In this example we implemented it as virtual property to include translations in REST API response when fetching data from database. Notice that method calls method getTranslations predefined in TranslatableMethods trait of Knp Doctrine2 behaviors bundle. So, basically, if your use-case scenario doesn’t require automatic binding of translations, you might call getTranslations method directly where and when you need it.

/**
* @SerializedName("translation")
* @Groups({"movie:default"})
*
* @return ArrayCollection
*/
public function getTranslation()
{
return $this->getTranslations();
}

Now when we implemented two of the mentioned methods, we can create service that will handle the actual translation based on given JSON object. JSON request body we will use is given in the following code snippet:

{
"releaseDate": "2019-07-14",
"translation": {
"en": {
"title": "Movie",
"description": "The movie that will take your breath away the moment you start watching it."
},
"de": {
"title": "Film",
"description": "Der Film, der Ihnen den Atem rauben wird, sobald Sie ihn sehen."
},
"fr": {
"title": "Film",
"description": "Le film qui vous coupera le souffle dès que vous commencerez à le regarder."
}
}
}

You can modify this JSON regarding to your needs (mind that you will have to update foreach loop logic in translateEntity method of TranslationService).

<?phpnamespace App\Service;use App\Entity\EntityInterface;
use Symfony\Component\HttpFoundation\Request;
class TranslationService
{
public function translateEntity(Request $request, EntityInterface $entity)
{
$requestData = json_decode($request->getContent());
$translations = $requestData->translation;
foreach ($translations as $lang => $value) {
$entity->setTranslatableProperties($lang, $value);
}
// In order to persist new translations, call mergeNewTranslations method, before flush
$entity->mergeNewTranslations();
return $entity;
}
}

The final thing we need to do is to create movieController that will handle requests for CRUD functionalites. In this example, I used dependency injection (autowiring) to inject few of the services we will need in our controller methods.

<?phpnamespace App\Controller\Api;use App\Entity\Movie;
use App\Factory\EntityFactory;
use App\Repository\MovieRepository;
use App\Service\TranslationService;
use DateTime;
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\ORMException;
use Exception;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Serializer\SerializerInterface;
class MovieController
{
private $repository;
private $serializer;
private $translationService;
private $entityManager;
public function __construct(MovieRepository $movieRepository, SerializerInterface $serializer, EntityManagerInterface $entityManager, TranslationService $translationService)
{
$this->repository = $movieRepository;
$this->serializer = $serializer;
$this->translationService = $translationService;
$this->entityManager = $entityManager;
}
//...
}

The READ functionality doesn’t require any additional work for translations since everything is done automatically in our getTranslation() method.

/**
* @Route(path="/movies", methods={"GET"}, name="movie_index")
*/
public function index(): JsonResponse
{
$movies = $this->repository->findAll();
$data = $this->serializer->serialize($movies, 'json', ['groups' => ['movie:id', 'movie:default']]);
return new JsonResponse($data, JsonResponse::HTTP_OK, [], true);
}

It’s pretty much the same when fetching a single movie:

/**
* @Route(path="/movies/{movie}", methods={"GET"}, name="movie_get")
*/
public function get(Movie $movie): JsonResponse
{
$data = $this->serializer->serialize($movie, 'json', ['groups' => ['movie:id', 'movie:default']]);
return new JsonResponse($data, JsonResponse::HTTP_OK, [], true);
}

When creating new entity, we have to call translateEntity method before persisting and flushing the entity so it can map corresponding translations during the insertion.

/**
* @Route(path="/movies", methods={"POST"}, name="movie_create")
*
* @throws ORMException
* @throws Exception
*/
public function create(Request $request): JsonResponse
{
$movie = new Movie();
$releaseDate = new DateTime($request->request->get('releaseDate'));
$movie->setReleaseDate($releaseDate);
$movie = $this->translationService->translateEntity($request, $movie);$this->entityManager->persist($movie);
$this->entityManager->flush();
$data = $this->serializer->serialize($movie, 'json', ['groups' => ['movie:id', 'movie:default']]);return new JsonResponse($data, JsonResponse::HTTP_OK, [], true);
}

Update action is not much different than create action but there is one thing we need to pay attention to. As you may know, the usual flow of update action is:

  1. fetch the object from database
  2. update fields
  3. store the object with new values

There is one tricky part that is not covered in official documentation and it may look redundant but gave me a headache when I implemented translatable behavior for the first time.

Before calling mergeNewTranslations method (from within translateEntity method), we need to do the flush. This is a workaround to avoid integrity constraint violation for duplicate entry probably caused by conflict between background work of Knp Doctrine2 translatable behavior and lazy loading of translations when fetching the object. However, that was the only way to make the update action work (I might be missing something here, feel free to let me know if that’s the case).

/**
* @Route(path="/movies/{movie}", methods={"PUT"}, name="movie_update")
*
* @throws ORMException
* @throws Exception
*/
public function update(Request $request, Movie $movie): JsonResponse
{
$releaseDate = new DateTime($request->request->get('releaseDate'));
$movie->setReleaseDate($releaseDate);
$this->entityManager->flush();$movie = $this->translationService->translateEntity($request, $movie);$this->entityManager->persist($movie);
$this->entityManager->flush();
$data = $this->serializer->serialize($movie, 'json', ['groups' => ['movie:id', 'movie:default']]);
return new JsonResponse($data, JsonResponse::HTTP_OK, [], true);
}

The remaining delete functionality is pretty straight forward:

/**
* @Route(path="/movie/{movie}", methods={"DELETE"}, name="movie_delete")
*
* @throws ORMException
*/
public function delete(Movie $movie): JsonResponse
{
$this->entityManager->remove($movie);
$this->entityManager->flush();
return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT);
}

There is one additional cool feature you might need depending on your use-case scenario. Imagine you have a twig template where you need to use something like {{ movie.getTitle() }} to show movie’s title. To be able to use property getter which is defined in MovieTranslator class, you need to add the following to your translatable class (e.g. Movie):

public function __call($method, $arguments)
{
return $this->proxyCurrentLocaleTranslation($method, $arguments);
}

or do it with PropertyAccessor that ships with Symfony SE:

// if your methods don't take any required arguments
public function __call($method, $arguments)
{
return \Symfony\Component\PropertyAccess\PropertyAccess::createPropertyAccessor()->getValue($this->translate(), $method);
}

Mind that it will return translation in language that is defined as your default locale. In case you want to change your current locale, use setDefaultLocale($locale) method of TranslatableTrait.

Hopefully you will find this article helpful and feel free to share it around!

--

--