@@ -171,7 +171,7 @@ private function assertAttributesAreComplete(string $class): void
171171 }
172172
173173 if (0 === \count ($ this ->translatedProperties )) {
174- throw new RuntimeException ('No translatable properties attributed with #[Polyglot\Translatable] were found ' );
174+ throw new RuntimeException ('No translatable properties attributed with #[Polyglot\Translatable] (at the property level) or #[Polyglot\TranslatedProperty] (at the class level) were found ' );
175175 }
176176
177177 if (null === $ this ->primaryLocale ) {
@@ -187,30 +187,44 @@ private function findTranslatedProperties(ClassMetadata $cm, ClassMetadataFactor
187187
188188 $ reflectionService = $ classMetadataFactory ->getReflectionService ();
189189 $ translationClassMetadata = $ classMetadataFactory ->getMetadataFor ($ this ->translationClass ->getName ());
190+ $ reflectionClass = $ cm ->getReflectionClass ();
190191
191- /* Iterate all properties of the class, not only those mapped by Doctrine */
192- foreach ($ cm ->getReflectionClass ()->getProperties () as $ reflectionProperty ) {
193- $ propertyName = $ reflectionProperty ->name ;
192+ /*
193+ Collect all (propertyName => translationFieldname) candidates from both sources.
194+ Using propertyName as key ensures deduplication when both sources declare the same property.
195+ */
196+ $ candidates = []; // propertyName => translationFieldname|null
194197
195- /*
196- If the property is inherited from a parent class, and our parent entity class
197- already contains that declaration, we need not include it.
198- */
199- $ declaringClass = $ reflectionProperty ->getDeclaringClass ()->name ;
200- if ($ declaringClass !== $ cm ->name && $ cm ->parentClasses && is_a ($ cm ->parentClasses [0 ], $ declaringClass , true )) {
198+ /* Property-level #[Translatable] attributes */
199+ foreach ($ reflectionClass ->getProperties () as $ reflectionProperty ) {
200+ $ attributes = $ reflectionProperty ->getAttributes (Attribute \Translatable::class);
201+ if (!$ attributes || $ this ->isDeclaredByParentEntity ($ reflectionProperty , $ cm )) {
201202 continue ;
202203 }
203204
204- $ attributes = $ reflectionProperty ->getAttributes (Attribute \Translatable::class);
205+ $ candidates [$ reflectionProperty ->name ] = $ attributes [0 ]->newInstance ()->getTranslationFieldname ();
206+ }
207+
208+ /* Class-level #[TranslatedProperty] attributes */
209+ foreach ($ reflectionClass ->getAttributes (Attribute \TranslatedProperty::class) as $ classAttribute ) {
210+ $ attribute = $ classAttribute ->newInstance ();
211+ $ propertyName = $ attribute ->getPropertyName ();
212+
213+ if (!$ reflectionClass ->hasProperty ($ propertyName )) {
214+ throw new \InvalidArgumentException (sprintf ('Property "%s" not found in class "%s" (declared via #[TranslatedProperty]). ' , $ propertyName , $ cm ->name ));
215+ }
205216
206- if (! $ attributes ) {
217+ if ($ this -> isDeclaredByParentEntity ( $ reflectionClass -> getProperty ( $ propertyName ), $ cm ) ) {
207218 continue ;
208219 }
209220
210- $ attribute = $ attributes [0 ]->newInstance ();
221+ $ candidates [$ propertyName ] = $ attribute ->getTranslationFieldname ();
222+ }
223+
224+ /* Register all collected candidates */
225+ foreach ($ candidates as $ propertyName => $ translationFieldname ) {
211226 $ this ->translatedProperties [$ propertyName ] = $ reflectionService ->getAccessibleProperty ($ cm ->name , $ propertyName );
212- $ translationFieldname = $ attribute ->getTranslationFieldname () ?: $ propertyName ;
213- $ this ->translationFieldMapping [$ propertyName ] = $ reflectionService ->getAccessibleProperty ($ translationClassMetadata ->name , $ translationFieldname );
227+ $ this ->translationFieldMapping [$ propertyName ] = $ reflectionService ->getAccessibleProperty ($ translationClassMetadata ->name , $ translationFieldname ?: $ propertyName );
214228 }
215229 }
216230
@@ -250,6 +264,17 @@ private function findPrimaryLocale(ClassMetadata $cm): void
250264 }
251265 }
252266
267+ /*
268+ Returns true if the property is declared in a parent class that is already covered
269+ by our parent entity's metadata, so we need not include it again.
270+ */
271+ private function isDeclaredByParentEntity (\ReflectionProperty $ property , ClassMetadata $ cm ): bool
272+ {
273+ $ declaringClass = $ property ->getDeclaringClass ()->name ;
274+
275+ return $ declaringClass !== $ cm ->name && $ cm ->parentClasses && is_a ($ cm ->parentClasses [0 ], $ declaringClass , true );
276+ }
277+
253278 private function parseTranslationsEntity (ClassMetadata $ cm ): void
254279 {
255280 foreach ($ cm ->fieldMappings as $ fieldName => $ mapping ) {
0 commit comments