DEV Community

Cover image for Internationalization with API Platform: the other way
Titouan B
Titouan B

Posted on

3 2

Internationalization with API Platform: the other way

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!
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
view raw config.yml hosted with ❤ by GitHub

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;
}
}
view raw Locale.php hosted with ❤ by GitHub

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..................
}
view raw Company.php hosted with ❤ by GitHub
<?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
Enter fullscreen mode Exit fullscreen mode

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 !

Example of frontend UI
Example of frontend UI

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:

Demo Repository:

GitHub logo 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




Qodo Takeover

Introducing Qodo Gen 1.0: Transform Your Workflow with Agentic AI

Rather than just generating snippets, our agents understand your entire project context, can make decisions, use tools, and carry out tasks autonomously.

Read full post →

Top comments (2)

Collapse
 
tacman profile image
Tac Tacelosky

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.

Collapse
 
sakhrihoussem profile image
Sakhri Houssem

thank you very much

Billboard image

Create up to 10 Postgres Databases on Neon's free plan.

If you're starting a new project, Neon has got your databases covered. No credit cards. No trials. No getting in your way.

Try Neon for Free →

👋 Kindness is contagious

Please leave a ❤️ or a friendly comment on this post if you found it helpful!

Okay