Store translation messages in database in Symfony

Andrew
4 min readApr 23, 2019

--

— Change the button sign, please.
— Again?
— Yes, again. Change it to “Confirm”.
— But we already have made this in last week!
— Yes, but in the next few days the customer requested to change this to “Submit”, and today his choice is “back off all of this”.

The situation in the epigraph is not typical, but I see it at least a few times. It’s not too complicated — change the key translation in yaml/ xlf file, increase the version number, commit changes, send it to CI/CD-server and viola! New translation! But I think, it’s too many actions for only change “Confirm” to “Submit” (and, maybe, back “Confirm” in the next week).

Symfony framework suggests a beautiful way to store i18n messages: you can store it in simple yaml file as key: Value, in xlf file, which can be updated in special applications, po/ mo files tor gettext engine and so on — but all of this not suppose to edit messages in the website administration interface, and usually stored under version control mechanism. Not a thing for edit by the user (even an administrator).

Well, the best way is a store the messages in place for dynamic user content — database. It will affect app performance, but not too much — if you not support a really big application, it’s no matter. Symfony Translator component will be extended and can load any sources — including the database, of course — you need to make a class which implements The Symfony\Component\Translation\Loader\LoaderInterface, configure it in service container and that is all — for a first look. In fact, you need to create a “dummy” (empty) translation files with extensions from your configuration.

For example, the loader class:

class DbLoader implements LoaderInterface
{
/**
* @var EntityManagerInterface
*/
private $doctrine;
/**
* @var string
*/
private $entityClass;
/**
* DbLoader constructor.
* @param ContainerInterface $container
* @param EntityManagerInterface $doctrine
*/
public function __construct(ContainerInterface $container, EntityManagerInterface $doctrine)
{
$this->doctrine = $doctrine;
$this->entityClass = $container->getParameter('db_i18n.entity');
}
/**
* Loads a locale.
*
* @param mixed $resource A resource
* @param string $locale A locale
* @param string $domain The domain
*
* @return MessageCatalogue A MessageCatalogue instance
*
* @throws NotFoundResourceException when the resource cannot be found
* @throws InvalidResourceException when the resource cannot be loaded
*/
public function load($resource, $locale, $domain = 'messages')
{
$messages = $this->getRepository()->findByDomainAndLocale($domain, $locale);
$values = array_map(static function (EntityInterface $entity) {
return $entity->getTranslation();
}, $messages);
$catalogue = new MessageCatalogue($locale, [
$domain => $values,
]);
return $catalogue;
}
/**
* @return ObjectRepository
*/
public function getRepository(): TranslationRepositoryInterface
{
return $this->doctrine->getRepository($this->entityClass);
}
}

And configuration:

parameters:
db_i18n.entity: App\Entity\Translation
services:
translation.loader.db:
class: App\Translation\Loader\DbLoader
arguments:
- '@service_container'
- '@doctrine.orm.entity_manager'
tags:
- { name: translation.loader, alias: db }

In this configuration, we have a tag with alias: db, then, we need to <domain>.<locale>.db file in translation directory to trigger your loader. messages.en_US.db, for example.

The Entity is a simple and standard Doctrine entity:

/**
* @ORM\Entity(repositoryClass="Creative\DbI18nBundle\Repository\TranslationRepository")
*/
class Translation implements EntityInterface
{
/**
* @var UuidInterface
* @ORM\Id()
* @ORM\Column(type="uuid", unique=true)
* @ORM\GeneratedValue(strategy="CUSTOM")
* @ORM\CustomIdGenerator(class="Ramsey\Uuid\Doctrine\UuidGenerator")
*/
private $id;
/**
* @ORM\Column(type="string", length=255)
*/
private $domain;
/**
* @ORM\Column(type="string", length=2)
*/
private $locale;
/**
* @ORM\Column(type="string", length=255)
*/
private $key;
/**
* @ORM\Column(type="text")
*/
private $translation;
/**
* @return UuidInterface|null
*/
public function getId(): ?UuidInterface
{
return $this->id;
}
/**
* @return string|null
*/
public function getDomain(): ?string
{
return $this->domain;
}
/**
* @param string $domain
*
* @return Translation
*/
public function setDomain(string $domain): self
{
$this->domain = $domain;
return $this;
}
/**
* @return string|null
*/
public function getLocale(): ?string
{
return $this->locale;
}
/**
* @param string $locale
*
* @return Translation
*/
public function setLocale(string $locale): self
{
$this->locale = $locale;
return $this;
}
/**
* @return string|null
*/
public function getKey(): ?string
{
return $this->key;
}
/**
* @param string $key
*
* @return Translation
*/
public function setKey(string $key): self
{
$this->key = $key;
return $this;
}
/**
* @return string|null
*/
public function getTranslation(): ?string
{
return $this->translation;
}
/**
* @param string $translation
*
* @return Translation
*/
public function setTranslation(string $translation): self
{
$this->translation = $translation;
return $this;
}
}

and the repository has a helper functions

/**
* @method Translation|null find($id, $lockMode = null, $lockVersion = null)
* @method Translation|null findOneBy(array $criteria, array $orderBy = null)
* @method Translation[] findAll()
* @method Translation[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
class TranslationRepository extends ServiceEntityRepository implements TranslationRepositoryInterface
{
/**
* TranslationRepository constructor.
*
* @param RegistryInterface $registry
*/
public function __construct(RegistryInterface $registry)
{
parent::__construct($registry, Translation::class);
}
/**
* @param string $domain
* @param string $locale
*
* @return array|\Creative\DbI18nBundle\Interfaces\EntityInterface[]|\Doctrine\Common\Collections\Collection|mixed
*/
public function findByDomainAndLocale(string $domain, string $locale)
{
return $this->createQueryBuilder('t', 't.key')
->where('t.domain = :domain')
->andWhere('t.locale = :locale')
->setParameter('domain', $domain)
->setParameter('locale', $locale)
->getQuery()
->getResult();
}
}

This will work for you, and you can add your own (convenient for you) form, controller and view to CRUD messages.

I made a simple bundle for use it in my projects, please, feel free to use/fork it.

Happy coding!

--

--