From 6357d2e92fcecb6cae03987295842e9cc7d1458e Mon Sep 17 00:00:00 2001 From: Matthias Pigulla Date: Wed, 18 Mar 2026 18:53:36 +0100 Subject: [PATCH 1/4] Add class-level `#[TranslatedProperty]` configuration option --- src/Attribute/TranslatedProperty.php | 32 ++++++++ src/Doctrine/TranslatableClassMetadata.php | 55 +++++++++---- .../EntityInheritance_MappedSuperclass.php | 38 +++++++++ ...tityInheritance_MappedSuperclassEntity.php | 28 +++++++ ...ance_MappedSuperclassEntityTranslation.php | 25 ++++++ .../MappedSuperclassInheritanceTest.php | 82 +++++++++++++++++++ 6 files changed, 245 insertions(+), 15 deletions(-) create mode 100644 src/Attribute/TranslatedProperty.php create mode 100644 tests/Fixtures/Entity/EntityInheritance/EntityInheritance_MappedSuperclass.php create mode 100644 tests/Fixtures/Entity/EntityInheritance/EntityInheritance_MappedSuperclassEntity.php create mode 100644 tests/Fixtures/Entity/EntityInheritance/EntityInheritance_MappedSuperclassEntityTranslation.php create mode 100644 tests/Functional/MappedSuperclassInheritanceTest.php diff --git a/src/Attribute/TranslatedProperty.php b/src/Attribute/TranslatedProperty.php new file mode 100644 index 0000000..cbb43ef --- /dev/null +++ b/src/Attribute/TranslatedProperty.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Webfactory\Bundle\PolyglotBundle\Attribute; + +use Attribute; + +#[Attribute(Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)] +final class TranslatedProperty +{ + public function __construct( + private readonly string $propertyName, + private readonly ?string $translationFieldname = null, + ) { + } + + public function getPropertyName(): string + { + return $this->propertyName; + } + + public function getTranslationFieldname(): ?string + { + return $this->translationFieldname; + } +} diff --git a/src/Doctrine/TranslatableClassMetadata.php b/src/Doctrine/TranslatableClassMetadata.php index 0b13120..9af8864 100644 --- a/src/Doctrine/TranslatableClassMetadata.php +++ b/src/Doctrine/TranslatableClassMetadata.php @@ -171,7 +171,7 @@ private function assertAttributesAreComplete(string $class): void } if (0 === \count($this->translatedProperties)) { - throw new RuntimeException('No translatable properties attributed with #[Polyglot\Translatable] were found'); + throw new RuntimeException('No translatable properties attributed with #[Polyglot\Translatable] (at the property level) or #[Polyglot\TranslatedProperty] (at the class level) were found'); } if (null === $this->primaryLocale) { @@ -187,30 +187,44 @@ private function findTranslatedProperties(ClassMetadata $cm, ClassMetadataFactor $reflectionService = $classMetadataFactory->getReflectionService(); $translationClassMetadata = $classMetadataFactory->getMetadataFor($this->translationClass->getName()); + $reflectionClass = $cm->getReflectionClass(); - /* Iterate all properties of the class, not only those mapped by Doctrine */ - foreach ($cm->getReflectionClass()->getProperties() as $reflectionProperty) { - $propertyName = $reflectionProperty->name; + /* + Collect all (propertyName => translationFieldname) candidates from both sources. + Using propertyName as key ensures deduplication when both sources declare the same property. + */ + $candidates = []; // propertyName => translationFieldname|null - /* - If the property is inherited from a parent class, and our parent entity class - already contains that declaration, we need not include it. - */ - $declaringClass = $reflectionProperty->getDeclaringClass()->name; - if ($declaringClass !== $cm->name && $cm->parentClasses && is_a($cm->parentClasses[0], $declaringClass, true)) { + /* Property-level #[Translatable] attributes */ + foreach ($reflectionClass->getProperties() as $reflectionProperty) { + $attributes = $reflectionProperty->getAttributes(Attribute\Translatable::class); + if (!$attributes || $this->isDeclaredByParentEntity($reflectionProperty, $cm)) { continue; } - $attributes = $reflectionProperty->getAttributes(Attribute\Translatable::class); + $candidates[$reflectionProperty->name] = $attributes[0]->newInstance()->getTranslationFieldname(); + } + + /* Class-level #[TranslatedProperty] attributes */ + foreach ($reflectionClass->getAttributes(Attribute\TranslatedProperty::class) as $classAttribute) { + $attribute = $classAttribute->newInstance(); + $propertyName = $attribute->getPropertyName(); + + if (!$reflectionClass->hasProperty($propertyName)) { + throw new \InvalidArgumentException(sprintf('Property "%s" not found in class "%s" (declared via #[TranslatedProperty]).', $propertyName, $cm->name)); + } - if (!$attributes) { + if ($this->isDeclaredByParentEntity($reflectionClass->getProperty($propertyName), $cm)) { continue; } - $attribute = $attributes[0]->newInstance(); + $candidates[$propertyName] = $attribute->getTranslationFieldname(); + } + + /* Register all collected candidates */ + foreach ($candidates as $propertyName => $translationFieldname) { $this->translatedProperties[$propertyName] = $reflectionService->getAccessibleProperty($cm->name, $propertyName); - $translationFieldname = $attribute->getTranslationFieldname() ?: $propertyName; - $this->translationFieldMapping[$propertyName] = $reflectionService->getAccessibleProperty($translationClassMetadata->name, $translationFieldname); + $this->translationFieldMapping[$propertyName] = $reflectionService->getAccessibleProperty($translationClassMetadata->name, $translationFieldname ?: $propertyName); } } @@ -250,6 +264,17 @@ private function findPrimaryLocale(ClassMetadata $cm): void } } + /* + Returns true if the property is declared in a parent class that is already covered + by our parent entity's metadata, so we need not include it again. + */ + private function isDeclaredByParentEntity(\ReflectionProperty $property, ClassMetadata $cm): bool + { + $declaringClass = $property->getDeclaringClass()->name; + + return $declaringClass !== $cm->name && $cm->parentClasses && is_a($cm->parentClasses[0], $declaringClass, true); + } + private function parseTranslationsEntity(ClassMetadata $cm): void { foreach ($cm->fieldMappings as $fieldName => $mapping) { diff --git a/tests/Fixtures/Entity/EntityInheritance/EntityInheritance_MappedSuperclass.php b/tests/Fixtures/Entity/EntityInheritance/EntityInheritance_MappedSuperclass.php new file mode 100644 index 0000000..48bac5a --- /dev/null +++ b/tests/Fixtures/Entity/EntityInheritance/EntityInheritance_MappedSuperclass.php @@ -0,0 +1,38 @@ +id; + } + + public function setText(TranslatableInterface $text): void + { + $this->text = $text; + } + + public function getText(): TranslatableInterface|string|null + { + return $this->text; + } +} diff --git a/tests/Fixtures/Entity/EntityInheritance/EntityInheritance_MappedSuperclassEntity.php b/tests/Fixtures/Entity/EntityInheritance/EntityInheritance_MappedSuperclassEntity.php new file mode 100644 index 0000000..1ef17d3 --- /dev/null +++ b/tests/Fixtures/Entity/EntityInheritance/EntityInheritance_MappedSuperclassEntity.php @@ -0,0 +1,28 @@ +translations = new ArrayCollection(); + } +} diff --git a/tests/Fixtures/Entity/EntityInheritance/EntityInheritance_MappedSuperclassEntityTranslation.php b/tests/Fixtures/Entity/EntityInheritance/EntityInheritance_MappedSuperclassEntityTranslation.php new file mode 100644 index 0000000..dff97da --- /dev/null +++ b/tests/Fixtures/Entity/EntityInheritance/EntityInheritance_MappedSuperclassEntityTranslation.php @@ -0,0 +1,25 @@ +setTranslation('Basistext', 'de_DE'); + $entity->setText($t); + + self::import([$entity]); + + $loaded = $this->entityManager->find(EntityInheritance_MappedSuperclassEntity::class, $entity->getId()); + + self::assertSame('base text', $loaded->getText()->translate('en_GB')); + self::assertSame('Basistext', $loaded->getText()->translate('de_DE')); + } + + public function testAddTranslation(): void + { + $entityManager = $this->entityManager; + + $entity = new EntityInheritance_MappedSuperclassEntity(); + $entity->setText(new Translatable('base text')); + self::import([$entity]); + + $loaded = $entityManager->find(EntityInheritance_MappedSuperclassEntity::class, $entity->getId()); + $loaded->getText()->setTranslation('Basistext', 'de_DE'); + $entityManager->flush(); + + $entityManager->clear(); + $reloaded = $entityManager->find(EntityInheritance_MappedSuperclassEntity::class, $entity->getId()); + + self::assertSame('base text', $reloaded->getText()->translate('en_GB')); + self::assertSame('Basistext', $reloaded->getText()->translate('de_DE')); + } + + public function testUpdateTranslations(): void + { + $entityManager = $this->entityManager; + + $entity = new EntityInheritance_MappedSuperclassEntity(); + $t = new Translatable('old text'); + $t->setTranslation('alter Text', 'de_DE'); + $entity->setText($t); + self::import([$entity]); + + $loaded = $entityManager->find(EntityInheritance_MappedSuperclassEntity::class, $entity->getId()); + $loaded->getText()->setTranslation('new text'); + $loaded->getText()->setTranslation('neuer Text', 'de_DE'); + $entityManager->flush(); + + $entityManager->clear(); + $reloaded = $entityManager->find(EntityInheritance_MappedSuperclassEntity::class, $entity->getId()); + + self::assertSame('new text', $reloaded->getText()->translate('en_GB')); + self::assertSame('neuer Text', $reloaded->getText()->translate('de_DE')); + } +} From c46732fb71a97460932b8ba875087cea771883b7 Mon Sep 17 00:00:00 2001 From: mpdude <1202333+mpdude@users.noreply.github.com> Date: Wed, 18 Mar 2026 17:54:26 +0000 Subject: [PATCH 2/4] Fix CS with PHP-CS-Fixer --- src/Doctrine/TranslatableClassMetadata.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Doctrine/TranslatableClassMetadata.php b/src/Doctrine/TranslatableClassMetadata.php index 9af8864..8ee6936 100644 --- a/src/Doctrine/TranslatableClassMetadata.php +++ b/src/Doctrine/TranslatableClassMetadata.php @@ -13,6 +13,7 @@ use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\ORM\Mapping\ClassMetadataFactory; use Doctrine\Persistence\Mapping\RuntimeReflectionService; +use InvalidArgumentException; use Psr\Log\LoggerInterface; use ReflectionClass; use ReflectionProperty; @@ -211,7 +212,7 @@ private function findTranslatedProperties(ClassMetadata $cm, ClassMetadataFactor $propertyName = $attribute->getPropertyName(); if (!$reflectionClass->hasProperty($propertyName)) { - throw new \InvalidArgumentException(sprintf('Property "%s" not found in class "%s" (declared via #[TranslatedProperty]).', $propertyName, $cm->name)); + throw new InvalidArgumentException(\sprintf('Property "%s" not found in class "%s" (declared via #[TranslatedProperty]).', $propertyName, $cm->name)); } if ($this->isDeclaredByParentEntity($reflectionClass->getProperty($propertyName), $cm)) { @@ -268,7 +269,7 @@ private function findPrimaryLocale(ClassMetadata $cm): void Returns true if the property is declared in a parent class that is already covered by our parent entity's metadata, so we need not include it again. */ - private function isDeclaredByParentEntity(\ReflectionProperty $property, ClassMetadata $cm): bool + private function isDeclaredByParentEntity(ReflectionProperty $property, ClassMetadata $cm): bool { $declaringClass = $property->getDeclaringClass()->name; From 9785ef7242cf92f594dbefc09ba5fcc8dce8ad37 Mon Sep 17 00:00:00 2001 From: Matthias Pigulla Date: Wed, 18 Mar 2026 19:33:26 +0100 Subject: [PATCH 3/4] Move setter to where it makes more sense --- .../EntityInheritance_MappedSuperclass.php | 5 ----- .../EntityInheritance_MappedSuperclassEntity.php | 6 ++++++ 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/Fixtures/Entity/EntityInheritance/EntityInheritance_MappedSuperclass.php b/tests/Fixtures/Entity/EntityInheritance/EntityInheritance_MappedSuperclass.php index 48bac5a..1ff0cd1 100644 --- a/tests/Fixtures/Entity/EntityInheritance/EntityInheritance_MappedSuperclass.php +++ b/tests/Fixtures/Entity/EntityInheritance/EntityInheritance_MappedSuperclass.php @@ -26,11 +26,6 @@ public function getId(): ?int return $this->id; } - public function setText(TranslatableInterface $text): void - { - $this->text = $text; - } - public function getText(): TranslatableInterface|string|null { return $this->text; diff --git a/tests/Fixtures/Entity/EntityInheritance/EntityInheritance_MappedSuperclassEntity.php b/tests/Fixtures/Entity/EntityInheritance/EntityInheritance_MappedSuperclassEntity.php index 1ef17d3..31f1f6a 100644 --- a/tests/Fixtures/Entity/EntityInheritance/EntityInheritance_MappedSuperclassEntity.php +++ b/tests/Fixtures/Entity/EntityInheritance/EntityInheritance_MappedSuperclassEntity.php @@ -6,6 +6,7 @@ use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; use Webfactory\Bundle\PolyglotBundle\Attribute as Polyglot; +use Webfactory\Bundle\PolyglotBundle\TranslatableInterface; /** * An entity extending a mapped superclass, adding Polyglot support. The full Polyglot @@ -25,4 +26,9 @@ public function __construct() { $this->translations = new ArrayCollection(); } + + public function setText(TranslatableInterface $text): void + { + $this->text = $text; + } } From 5fd2d468a507594fb4b7fab5bd0a02ce56b63bfc Mon Sep 17 00:00:00 2001 From: Matthias Pigulla Date: Wed, 18 Mar 2026 19:37:45 +0100 Subject: [PATCH 4/4] Register the mapped superclass as well --- tests/Functional/MappedSuperclassInheritanceTest.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/Functional/MappedSuperclassInheritanceTest.php b/tests/Functional/MappedSuperclassInheritanceTest.php index b4596a1..3f94725 100644 --- a/tests/Functional/MappedSuperclassInheritanceTest.php +++ b/tests/Functional/MappedSuperclassInheritanceTest.php @@ -2,6 +2,7 @@ namespace Webfactory\Bundle\PolyglotBundle\Tests\Functional; +use Webfactory\Bundle\PolyglotBundle\Tests\Fixtures\Entity\EntityInheritance\EntityInheritance_MappedSuperclass; use Webfactory\Bundle\PolyglotBundle\Tests\Fixtures\Entity\EntityInheritance\EntityInheritance_MappedSuperclassEntity; use Webfactory\Bundle\PolyglotBundle\Tests\Fixtures\Entity\EntityInheritance\EntityInheritance_MappedSuperclassEntityTranslation; use Webfactory\Bundle\PolyglotBundle\Translatable; @@ -19,6 +20,7 @@ protected function setUp(): void parent::setUp(); self::setupSchema([ + EntityInheritance_MappedSuperclass::class, EntityInheritance_MappedSuperclassEntity::class, EntityInheritance_MappedSuperclassEntityTranslation::class, ]);