Last active
September 14, 2024 16:57
-
-
Save Nek-/fa1d4505535c83c51e03a44a80c95653 to your computer and use it in GitHub Desktop.
Entity translations implementation suggestion with Symfony 7.x & Doctrine 3 & EasyAdmin
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?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(); | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?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) | |
; | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?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(); | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?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); | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
declare(strict_types=1); | |
namespace App\Tooling\Translation; | |
use App\Repository\LocaleRepository; | |
use Symfony\Component\HttpFoundation\RequestStack; | |
// Warning: | |
// This class highly depends on how you implements locale in your application | |
// you may want to change it entirely. | |
class LocaleProvider | |
{ | |
public function __construct(private RequestStack $requestStack, private LocaleRepository $localeRepository, private string $defaultLocale) | |
{ | |
} | |
public function provideCurrentLocale(): string | |
{ | |
if (null === $request = $this->requestStack->getMainRequest()) { | |
return $this->defaultLocale; | |
} | |
$userLocale = $request->getLocale(); | |
$locale = $this->localeRepository->findOneBy(['code' => $userLocale]); | |
if (null === $locale) { | |
return $this->defaultLocale; | |
} | |
return $locale->getCode(); | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?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; | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?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()); | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?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 ($this instanceof \Doctrine\Persistence\Proxy) { | |
$this->__load(); | |
} | |
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'; | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?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; | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?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) | |
; | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?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; | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?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
Hello Max, can you please also show the code from the LocaleProvider?
Thanks in advance.