Skip to content

Instantly share code, notes, and snippets.

@Nek-
Last active July 8, 2024 13:51
Show Gist options
  • Save Nek-/fa1d4505535c83c51e03a44a80c95653 to your computer and use it in GitHub Desktop.
Save Nek-/fa1d4505535c83c51e03a44a80c95653 to your computer and use it in GitHub Desktop.
Entity translations implementation suggestion with Symfony 7.x & Doctrine 3 & EasyAdmin
<?php
//
// Example of implementation of an entity translatable
//
declare(strict_types=1);
namespace App\Entity;
use App\Tooling\Translation\Model\TranslatableInterface;
use App\Tooling\Translation\Model\TranslatableTrait;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
/** @implements TranslatableInterface<CategoryTranslation> */
#[ORM\Entity()]
class Category implements TranslatableInterface
{
/** @use TranslatableTrait<CategoryTranslation> */
use TranslatableTrait;
#[ORM\Id]
#[ORM\Column]
private string $id;
public function __construct()
{
$this->id = Uuid::v4()->toRfc4122();
}
public function getId(): string
{
return $this->id;
}
public function getName(): string
{
return $this->getAnyTranslation()->getName();
}
}
<?php
//
// Example of usage within easyadmin
//
declare(strict_types=1);
namespace App\Controller\Admin;
use App\Form\CategoryTranslationType;
use App\Entity\Category;
use App\Tooling\Translation\Admin\TranslationsField;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField;
use EasyCorp\Bundle\EasyAdminBundle\Field\TextField;
class CategoryCrudController extends AbstractCrudController
{
public static function getEntityFqcn(): string
{
return Category::class;
}
public function configureFields(string $pageName): iterable
{
yield TextField::new('name')->hideOnForm();
yield TranslationsField::new('translations')
->setFormTypeOption('entry_type', CategoryTranslationType::class)
;
}
}
<?php
//
// Example of implementation of an entity translation
//
declare(strict_types=1);
namespace App\Entity;
use App\Tooling\Translation\Model\TranslationInterface;
use App\Tooling\Translation\Model\TranslationTrait;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
/** @implements TranslationInterface<Category> */
#[ORM\Entity]
class CategoryTranslation implements TranslationInterface
{
/** @use TranslationTrait<Category> */
use TranslationTrait;
#[ORM\Id]
#[ORM\Column]
private string $id;
#[ORM\Column]
private ?string $name = null;
public function __construct()
{
$this->id = Uuid::v4()->toRfc4122();
}
public function getId(): string
{
return $this->id;
}
public function getName(): ?string
{
return $this->name;
}
public function setName(string $name): void
{
$this->name = $name;
$this->generateHierarchy();
}
}
<?php
//
// Example of usage in a form designed for easyadmin
//
declare(strict_types=1);
namespace App\Form\Admin;
use App\Entity\CategoryTranslation;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class CategoryTranslationType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add('name');
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefault('data_class', CategoryTranslation::class);
}
}
<?php
declare(strict_types=1);
namespace App\Tooling\Translation\Model;
use Doctrine\Common\Collections\Collection;
/**
* @template T of TranslationInterface
*/
interface TranslatableInterface
{
/**
* @return class-string<T>
*/
public static function getTranslationEntityClass(): string;
public function setCurrentLocale(string $locale): void;
/**
* @return Collection<string, T>
*/
public function getTranslations(): Collection;
/**
* @return T
*/
public function getTranslation(?string $locale = null): TranslationInterface;
/**
* @return T
*/
public function getAnyTranslation(): TranslationInterface;
}
<?php
declare(strict_types=1);
namespace App\Tooling\Translation\EventListener;
use App\Tooling\Translation\LocaleProvider;
use App\Tooling\Translation\Model\TranslatableInterface;
use App\Tooling\Translation\Model\TranslationInterface;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\Common\EventSubscriber;
use Doctrine\ORM\Event\LoadClassMetadataEventArgs;
use Doctrine\ORM\Event\PostLoadEventArgs;
use Doctrine\ORM\Events;
use Doctrine\ORM\Mapping\ClassMetadata;
/**
* This class is heavily inspired by the Sylius ORMTranslatableListener.
*
* @template Translatable of TranslatableInterface
* @template Translation of TranslationInterface
*/
#[AsDoctrineListener(event: Events::loadClassMetadata, connection: 'default')]
#[AsDoctrineListener(event: Events::postLoad, connection: 'default')]
class TranslatableListener implements EventSubscriber
{
public function __construct(private LocaleProvider $localeProvider)
{
}
public function getSubscribedEvents(): array
{
return [
Events::loadClassMetadata,
Events::postLoad,
];
}
/**
* Add mapping to translatable entities.
*/
public function loadClassMetadata(LoadClassMetadataEventArgs $eventArgs): void
{
$classMetadata = $eventArgs->getClassMetadata();
$reflection = $classMetadata->getReflectionClass();
if ($reflection->isAbstract()) {
return;
}
if ($reflection->implementsInterface(TranslatableInterface::class)) {
$this->mapTranslatable($classMetadata);
}
if ($reflection->implementsInterface(TranslationInterface::class)) {
$this->mapTranslation($classMetadata);
}
}
/**
* Add mapping data to a translatable entity.
*
* @param ClassMetadata<Translatable> $metadata
*/
private function mapTranslatable(ClassMetadata $metadata): void
{
/** @var class-string<Translatable> $className */
$className = $metadata->name;
if (!$metadata->hasAssociation('translations')) {
$metadata->mapOneToMany([
'fieldName' => 'translations',
'targetEntity' => $className::getTranslationEntityClass(),
'mappedBy' => 'translatable',
'fetch' => ClassMetadata::FETCH_EAGER,
'indexBy' => 'locale',
'cascade' => ['persist', 'remove', 'detach', 'refresh'],
'orphanRemoval' => true,
]);
}
}
/**
* Add mapping data to a translation entity.
*
* @param ClassMetadata<Translation> $metadata
*/
private function mapTranslation(ClassMetadata $metadata): void
{
/** @var class-string<Translation> $className */
$className = $metadata->name;
if (!$metadata->hasAssociation('translatable')) {
$metadata->mapManyToOne([
'fieldName' => 'translatable',
'targetEntity' => $className::getTranslatableEntityClass(),
'inversedBy' => 'translations',
'fetch' => ClassMetadata::FETCH_EAGER,
'joinColumns' => [[
'name' => 'translatable_id',
'referencedColumnName' => 'id',
'onDelete' => 'CASCADE',
'nullable' => false,
]],
]);
}
if (!$metadata->hasField('locale')) {
$metadata->mapField([
'fieldName' => 'locale',
'type' => 'string',
'nullable' => false,
]);
}
// Map unique index.
$columns = [
$metadata->getSingleAssociationJoinColumnName('translatable'),
'locale',
];
if (!$this->hasUniqueConstraint($metadata, $columns)) {
$constraints = $metadata->table['uniqueConstraints'] ?? [];
$constraints[$metadata->getTableName() . '_uniq_trans'] = [
'columns' => $columns,
];
$metadata->setPrimaryTable([
'uniqueConstraints' => $constraints,
]);
}
}
/**
* Check if a unique constraint has been defined.
*
* @param ClassMetadata<Translation> $metadata
* @param array<int,string> $columns
*/
private function hasUniqueConstraint(ClassMetadata $metadata, array $columns): bool
{
if (!isset($metadata->table['uniqueConstraints'])) {
return false;
}
foreach ($metadata->table['uniqueConstraints'] as $constraint) {
if (!array_diff($constraint['columns'], $columns)) {
return true;
}
}
return false;
}
public function postLoad(PostLoadEventArgs $args): void
{
$entity = $args->getObject();
if (!$entity instanceof TranslatableInterface) {
return;
}
$entity->setCurrentLocale($this->localeProvider->provideCurrentLocale());
}
}
<?php
declare(strict_types=1);
namespace App\Tooling\Translation\Model;
use App\Tooling\Exception\NoTranslationFoundException;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Symfony\Component\Serializer\Annotation\Groups;
/**
* @template T of TranslationInterface
*/
trait TranslatableTrait
{
/**
* @var Collection<string, T>
*/
#[Groups(['list-products'])]
private ?Collection $translations = null;
private ?string $currentLocale = null;
public function getTranslations(): Collection
{
return $this->translations ?? $this->translations = new ArrayCollection();
}
public function getAnyTranslation(): TranslationInterface
{
try {
return $this->getTranslation();
} catch (NoTranslationFoundException $e) {
}
if (0 === $this->translations->count()) {
throw new NoTranslationFoundException('No translation found for translatable entity.');
}
return $this->getTranslations()->first();
}
/**
* @return T
*/
public function getTranslation(?string $locale = null): TranslationInterface
{
if (null === $this->currentLocale && null === $locale) {
throw new NoTranslationFoundException('No locale has been set and current locale is undefined. See TranslatableListener::postLoad().');
}
if (null === $locale) {
$locale = $this->currentLocale;
} elseif (null === $this->currentLocale) {
$this->currentLocale = $locale;
}
if ($this->getTranslations()->containsKey($locale)) {
return $this->getTranslations()->get($locale);
}
$translationClass = self::getTranslationEntityClass();
$translation = new $translationClass();
$translation->setLocale($locale);
$translation->setTranslatable($this);
$this->getTranslations()->set($locale, $translation);
return $translation;
}
public function setCurrentLocale(string $currentLocale): void
{
$this->currentLocale = $currentLocale;
}
/**
* @param T $translation
*/
public function addTranslation(TranslationInterface $translation): void
{
$this->getTranslations()->add($translation);
}
/**
* @param T $translation
*/
public function removeTranslation(TranslationInterface $translation): void
{
$this->getTranslations()->removeElement($translation);
}
public static function getTranslationEntityClass(): string
{
return static::class . 'Translation';
}
}
<?php
declare(strict_types=1);
namespace App\Tooling\Translation\Model;
/**
* @template T of TranslatableInterface
*/
interface TranslationInterface
{
/**
* @return class-string<T>
*/
public static function getTranslatableEntityClass(): string;
public function getLocale(): string;
public function setLocale(string $locale): void;
/**
* @param T $translatable
*/
public function setTranslatable(TranslatableInterface $translatable): void;
public function getLanguage(): string;
}
<?php
declare(strict_types=1);
namespace App\Tooling\Translation\Admin;
use App\Tooling\Translation\Form\TranslationsType;
use EasyCorp\Bundle\EasyAdminBundle\Contracts\Field\FieldInterface;
use EasyCorp\Bundle\EasyAdminBundle\Field\FieldTrait;
use Symfony\Contracts\Translation\TranslatableInterface;
class TranslationsField implements FieldInterface
{
use FieldTrait;
public function setValue(mixed $value): self
{
$this->dto->setValue($value);
return $this;
}
public static function new(string $propertyName, TranslatableInterface|string|false|null $label = null): self
{
return (new self())
->setProperty($propertyName)
->setLabel($label)
->onlyOnForms()
->addFormTheme('Admin/Form/translations.html.twig')
->setRequired(true)
->setFormType(TranslationsType::class)
;
}
}
<?php
declare(strict_types=1);
namespace App\Tooling\Translation\Form;
use App\Tooling\Translation\AvailableLocalesProvider;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\OptionsResolver;
class TranslationsType extends AbstractType
{
public function __construct(private AvailableLocalesProvider $availableLocalesProvider)
{
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event): void {
$translations = $event->getData();
$model = $event->getForm()->getParent()->getData();
foreach ($this->availableLocalesProvider->getAvailableLocales() as $locale) {
if ($translations->containsKey($locale->getCode())) {
continue;
}
$translations->set($locale->getCode(), $model->getTranslation($locale->getCode()));
}
$event->setData($translations);
}, 900); // Priority 900 is important to take over on CollectionType listeners
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'allow_add' => false,
'allow_delete' => false,
]);
}
public function getParent()
{
return CollectionType::class;
}
}
<?php
declare(strict_types=1);
namespace App\Tooling\Translation\Model;
use Nekland\Tools\StringTools;
use Symfony\Component\Serializer\Annotation\Groups;
/**
* @template T of TranslatableInterface
*/
trait TranslationTrait
{
/**
* @var T
*/
private TranslatableInterface $translatable;
#[Groups(['list-products'])]
private string $locale;
/**
* @return T
*/
public function getTranslatable(): TranslatableInterface
{
return $this->translatable;
}
public function getLocale(): string
{
return $this->locale;
}
public function setLocale(string $locale): void
{
$this->locale = $locale;
}
public function getLanguage(): string
{
[$language] = explode('_', $this->locale);
return strtolower($language);
}
public function getCountry(): string
{
[,$country] = explode('_', $this->locale);
return strtolower($country);
}
/**
* @param T $translatable
*/
public function setTranslatable(TranslatableInterface $translatable): void
{
$this->translatable = $translatable;
}
public static function getTranslatableEntityClass(): string
{
return StringTools::removeEnd(static::class, 'Translation');
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment