We recently saw the great article of Locastic on this tricky subject of implementing translations in API and specifically in API Platform
API Platform, API Platform everywhere!
We recently saw the great article of Locastic on this tricky subject of implementing translations in API and specifically in API Platform
For one of our project, we also find a pretty neat solution to implement intl in our beloved API Platform!
First of all, we have locale parameters and locale Entity to manage locales stuff and language reference.
We add some parameters:
parameters: | |
locale: en | |
availableLocales: | |
- en | |
- fr |
You can also use Locale Entity and add new locales in database. The Locale Entity has priority over parameters. If there is no Locale in database, it will use parameters config.
Our Locale Entity 🏁
<?php | |
namespace App\Entity; | |
use ApiPlatform\Core\Annotation\ApiResource; | |
use Doctrine\ORM\Mapping as ORM; | |
use Symfony\Component\Validator\Constraints as Assert; | |
use Symfony\Component\Serializer\Annotation\Groups; | |
/** | |
* Locale | |
* | |
* @ApiResource( | |
* itemOperations={ | |
* "get"={"method"="GET"}, | |
* "put"={"method"="PUT", "access_control"="is_granted('ROLE_ADMIN')"}, | |
* "delete"={"method"="DELETE", "access_control"="is_granted('ROLE_ADMIN')"} | |
* }, | |
* collectionOperations = { | |
* "get"={"method"="GET"}, | |
* "post"={"method"="POST","access_control"="is_granted('ROLE_ADMIN')"}, | |
* }, | |
* ) | |
* @ORM\Table(name="locale") | |
* @ORM\Entity() | |
*/ | |
class Locale | |
{ | |
/** | |
* @var int | |
* | |
* @ORM\Column(name="id", type="integer") | |
* @ORM\Id | |
* @ORM\GeneratedValue(strategy="AUTO") | |
* @Groups({"out"}) | |
*/ | |
private $id; | |
/** | |
* @var string | |
* | |
* @Assert\NotBlank() | |
* @Assert\Length( | |
* min = 1, | |
* max = 7, | |
* minMessage = "Locale code must be at least {{ limit }} characters long", | |
* maxMessage = "Locale code cannot be longer than {{ limit }} characters" | |
* ) | |
* @ORM\Column(name="code", type="string", length=7) | |
* @Groups({"out"}) | |
*/ | |
private $code; | |
/** | |
* @var string | |
* | |
* @Assert\NotBlank() | |
* @Assert\Length( | |
* min = 1, | |
* max = 45, | |
* minMessage = "Locale name must be at least {{ limit }} characters long", | |
* maxMessage = "Locale name cannot be longer than {{ limit }} characters" | |
* ) | |
* @ORM\Column(name="name", type="string", length=45) | |
* @Groups({"out"}) | |
*/ | |
private $name; | |
/** | |
* @var bool | |
* | |
* @ORM\Column(type="boolean") | |
* @Groups({"out"}) | |
*/ | |
private $isDefault = false; | |
/** | |
* Get id | |
* | |
* @return int | |
*/ | |
public function getId() | |
{ | |
return $this->id; | |
} | |
/** | |
* Set code | |
* | |
* @param string $code | |
* | |
* @return Locale | |
*/ | |
public function setCode($code) | |
{ | |
$this->code = $code; | |
return $this; | |
} | |
/** | |
* Get code | |
* | |
* @return string | |
*/ | |
public function getCode() | |
{ | |
return $this->code; | |
} | |
/** | |
* Set name | |
* | |
* @param string $name | |
* | |
* @return Locale | |
*/ | |
public function setName($name) | |
{ | |
$this->name = $name; | |
return $this; | |
} | |
/** | |
* Get name | |
* | |
* @return string | |
*/ | |
public function getName() | |
{ | |
return $this->name; | |
} | |
/** | |
* @return int | |
*/ | |
public function getIsDefault() | |
{ | |
return $this->isDefault; | |
} | |
/** | |
* @param bool $default | |
*/ | |
public function setIsDefault($default = false) | |
{ | |
$this->isDefault = $default; | |
} | |
} |
A little LocaleRepository to handle the switch between config and parameters:
<?php | |
namespace App\Repository; | |
use App\Entity\Locale; | |
use Doctrine\ORM\EntityManager; | |
use Doctrine\ORM\EntityRepository; | |
final class LocaleRepository | |
{ | |
/** | |
* @var Repository | |
*/ | |
private $repository; | |
/** | |
* @var string | |
*/ | |
private $parametersDefaultLocale; | |
/** | |
* @var array | |
*/ | |
private $parametersAvailableLocales; | |
public function __construct(string $parametersDefaultLocale, array $parametersAvailableLocales, EntityManager $entityManager) | |
{ | |
$this->repository = $entityManager->getRepository(Locale::class); | |
$this->parametersDefaultLocale = $parametersDefaultLocale; | |
$this->parametersAvailableLocales = $parametersAvailableLocales; | |
} | |
/** | |
* Return defaultLocale code | |
* @return string | |
*/ | |
public function getDefaultLocale() | |
{ | |
$defaultLocale = $this->parametersDefaultLocale; | |
$dbDefaultLocale = $this->repository->findOneBy(array('isDefault'=>true)); | |
if($dbDefaultLocale){ | |
$defaultLocale = $dbDefaultLocale->getCode(); | |
} | |
return $defaultLocale; | |
} | |
/** | |
* Return array of availableLocale code | |
* @return array | |
*/ | |
public function getAvailableLocales() | |
{ | |
$qb = $this->repository->createQueryBuilder('l'); | |
$qb->select('l.code AS locales'); | |
$result = $qb->getQuery()->getResult(); | |
$availableLocales = array_map(function($el){ return $el['locales']; }, $result); | |
if(empty($availableLocales)){ | |
$availableLocales = $this->parametersAvailableLocales; | |
} | |
return $availableLocales; | |
} | |
} |
Note: We use autowiring to pass parameters parametersDefaultLocale and parametersAvailableLocales to the constructor.
Now we need to translate our Entities !
We choose to use Doctrine Translatable Extension to manage our Translatable Entities.
Pretty simple! We only followed the Doctrine Translatable Extension doc to give our translatable behavior to our Entities. Here is an example of a translatable entity:
<?php | |
namespace App\Entity; | |
use ApiPlatform\Core\Annotation\ApiResource; | |
use ApiPlatform\Core\Annotation\ApiSubresource; | |
use Doctrine\ORM\Mapping as ORM; | |
use Gedmo\Mapping\Annotation as Gedmo; | |
use Symfony\Component\Validator\Constraints as Assert; | |
use Symfony\Component\Serializer\Annotation\MaxDepth; | |
use Symfony\Component\Serializer\Annotation\Groups; | |
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; | |
/** | |
* @ApiResource() | |
* @ORM\Entity() | |
* @ORM\Table(name="company") | |
* @Gedmo\TranslationEntity(class="App\Entity\Translation\CompanyTranslation") | |
* @UniqueEntity("name") | |
*/ | |
class Company | |
{ | |
/** | |
* @var int | |
* | |
* @ORM\Id | |
* @ORM\Column(type="integer") | |
* @ORM\GeneratedValue(strategy="AUTO") | |
* @Groups({"out"}) | |
*/ | |
private $id; | |
/** | |
* @var string | |
* | |
* @Assert\NotBlank() | |
* @ORM\Column(type="string", unique=true) | |
* @Groups({"out", "in", "recruiter_in"}) | |
*/ | |
private $name; | |
/** | |
* @var string | |
* | |
* @Gedmo\Translatable | |
* @ORM\Column(type="text", nullable=true) | |
* @Groups({"out", "in", "recruiter_in"}) | |
*/ | |
private $description; | |
/** | |
* @var string | |
* | |
* @Gedmo\Translatable | |
* @ORM\Column(type="string", nullable=true) | |
* @Groups({"out", "in", "recruiter_in"}) | |
*/ | |
private $catchPhrase; | |
/** | |
* @var string | |
* | |
* @ORM\Column(type="string", nullable=false) | |
* @Gedmo\Slug(fields={"name"}) | |
* @Groups({"out"}) | |
*/ | |
private $slug; | |
/** | |
* @var Locale | |
* | |
* @Gedmo\Locale | |
*/ | |
private $locale; | |
public function __toString() | |
{ | |
return $this->name; | |
} | |
............. SETTERS/GETTERS.................. | |
} |
<?php | |
namespace App\Entity\Translation; | |
use Doctrine\ORM\Mapping as ORM; | |
use Gedmo\Translatable\Entity\MappedSuperclass\AbstractTranslation; | |
/** | |
* @ORM\Table(name="company_translations", indexes={ | |
* @ORM\Index(name="company_translation_idx", columns={"locale", "object_class", "field", "foreign_key"}) | |
* }) | |
* @ORM\Entity(repositoryClass="Gedmo\Translatable\Entity\Repository\TranslationRepository") | |
*/ | |
class CompanyTranslation extends AbstractTranslation | |
{ | |
/** | |
* All required columns are mapped through inherited superclass | |
*/ | |
} |
Pretty easy, isn’t it? 👌
But how can this work? Where is the magic trick? How can I know which locale is use or how can I add new languages and translations?
Here is the magic trick, we have a LocaleListener!
<?php | |
namespace App\EventListener; | |
use App\Entity\Locale; | |
use App\Repository\LocaleRepository; | |
use Symfony\Component\EventDispatcher\EventSubscriberInterface; | |
use Symfony\Component\HttpKernel\KernelEvents; | |
use Symfony\Component\HttpKernel\Event\GetResponseEvent; | |
use Symfony\Component\HttpKernel\Event\FilterResponseEvent; | |
use Symfony\Component\HttpFoundation\Request; | |
use Gedmo\Translatable\TranslatableListener; | |
class LocaleListener implements EventSubscriberInterface | |
{ | |
private $availableLocales; | |
private $defaultLocale; | |
private $translatableListener; | |
protected $currentLocale; | |
public function __construct(TranslatableListener $translatableListener, LocaleRepository $localeRepository) | |
{ | |
$this->translatableListener = $translatableListener; | |
$this->availableLocales = $localeRepository->getAvailableLocales(); | |
$this->defaultLocale = $localeRepository->getDefaultLocale(); | |
} | |
public static function getSubscribedEvents() | |
{ | |
return array( | |
KernelEvents::REQUEST => array(array('onKernelRequest', 200)), | |
KernelEvents::RESPONSE => array('setContentLanguage') | |
); | |
} | |
public function onKernelRequest(GetResponseEvent $event) | |
{ | |
// Persist DefaultLocale in translation table | |
$this->translatableListener->setPersistDefaultLocaleTranslation(true); | |
/** @var Request $request */ | |
$request = $event->getRequest(); | |
if ($request->headers->has("X-LOCALE")) { | |
$locale = $request->headers->get('X-LOCALE'); | |
if (in_array($locale, $this->availableLocales)) { | |
$request->setLocale($locale); | |
} else { | |
$request->setLocale($this->defaultLocale); | |
} | |
} else { | |
$request->setLocale($this->defaultLocale); | |
} | |
// Set currentLocale | |
$this->translatableListener->setTranslatableLocale($request->getLocale()); | |
$this->currentLocale = $request->getLocale(); | |
} | |
/** | |
* @param FilterResponseEvent $event | |
* @return \Symfony\Component\HttpFoundation\Response | |
*/ | |
public function setContentLanguage(FilterResponseEvent $event) | |
{ | |
$response = $event->getResponse(); | |
$response->headers->add(array('Content-Language' => $this->currentLocale)); | |
return $response; | |
} | |
} |
When you call the API (GET, POST, PUT, PATCH), you want to retrieve data (or post data) for a specific locale. You can do this by passing the locale in the header of your request:
X-LOCALE: fr
If your locale is null or not supported, it will use the default locale.
❤️ I love this solution because it is simple and use standard library such as Doctrine Translatable which is mainly use in Symfony application.
Add to that, it is stateless and stay REST compliant (when we GET/POST/PUT… we stay in the Entity context).
If you want multi-language editable field, just create it with your favorite frontend framework and change the header X-LOCALE to the correct language on the POST/PUT request.
For example, we retrieve available locales through the API locale endpoint to display flags 🇫🇷 and edit button ✏️. When you click on edit button, it will go to the edit form with the correct language data thanks to the correct X-LOCALE header in the GET request. And when you submit the form, it calls a PUT on the entity with the correct X-LOCALE header set to the selected language !
Note: with the approach we have the same entity id for all languages. The company id=42 will always be the company 42 in different languages. Only Translatable Annotated field will change. So be sure it is ok with your use case. I don’t think there is a unique and perfect solution for the intl problem.
Thanks for reading and feel free to ask anything in comment! 🚀
From Medium:

Internationalization with API Platform: the other way | by Titouan BENOIT | Medium
Titouan BENOIT ・ ・
Medium
Demo Repository:
Nightbr
/
api-intl
Symfony4 flex with API platform with Doctrine Translatable: https://medium.com/@titouanbenoit/internationalization-with-api-platform-the-other-way-5ce9c446737f
api-intl
Top comments (2)
Thanks for the article. I think setting the locale in the headers is problematic, though, because the same REST request returns different results. Among other things, this makes caching impossible. I guess if users are always in the same language it's fine, but often it's helpful to have the option to switch languages, especially if you're looking at automated translations and want to go back to the original.
thank you very much