From 51d152662b2b6b668f643101d9121976febe128e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Mon, 5 Oct 2026 22:12:30 +0200 Subject: [PATCH] feat(serializer): denormalize into the declared collection class A property that declares a collection class, such as a Doctrine collection, was denormalized into a plain array. The item normalizer kept only the element class and asked the Serializer for "Foo[]", then the property accessor could not write that array into the collection. The declared container class is now passed to the Serializer, together with the element and key types in the "value_type" and "key_type" context entries. The core Serializer uses those entries to carry the element type of a collection, and the Doctrine bridge collection denormalizer builds the collection from them. The class is checked with class_exists, so older Symfony versions keep the "Foo[]" type. A container that is a plain array or iterable is not affected. --- src/Serializer/AbstractItemNormalizer.php | 28 ++++++++ .../Tests/AbstractItemNormalizerTest.php | 65 +++++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/src/Serializer/AbstractItemNormalizer.php b/src/Serializer/AbstractItemNormalizer.php index 040ac4f0ef..6e1e52b970 100644 --- a/src/Serializer/AbstractItemNormalizer.php +++ b/src/Serializer/AbstractItemNormalizer.php @@ -28,6 +28,7 @@ use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\Metadata\Util\ClassInfoTrait; use ApiPlatform\Metadata\Util\CloneTrait; +use Symfony\Bridge\Doctrine\Serializer\Normalizer\CollectionDenormalizer; use Symfony\Component\PropertyAccess\Exception\InvalidArgumentException as PropertyAccessInvalidArgumentException; use Symfony\Component\PropertyAccess\Exception\InvalidTypeException; use Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException; @@ -1110,6 +1111,19 @@ private function createAndValidateAttributeValue(string $attribute, mixed $value unset($context['resource_class'], $context['uri_variables']); try { + // When the collection is declared with a class, such as a Doctrine collection, + // ask the Serializer to build that class and pass the element and key types + // through the context, the same way the core Serializer does. The class is + // checked so older Symfony versions, which cannot build the collection, keep + // the array type. A plain array or iterable keeps the "Foo[]" type too. + $collectionClass = self::getCollectionContainerClass($t); + if (null !== $collectionClass && class_exists(CollectionDenormalizer::class)) { + $context['value_type'] = $unwrappedCollectionValueType; + $context['key_type'] = $t->getCollectionKeyType(); + + return $this->serializer->denormalize($value, $collectionClass, $format, $context); + } + return $this->serializer->denormalize($value, $className.'[]', $format, $context); } catch (NotNormalizableValueException $e) { // union/intersect types: try the next type, if not valid, an exception will be thrown at the end @@ -1272,6 +1286,20 @@ private function createAndValidateAttributeValue(string $attribute, mixed $value return $value; } + /** + * Returns the class of the container of a collection type, or null when the container is a plain array or iterable. + */ + private static function getCollectionContainerClass(CollectionType $type): ?string + { + $containerType = $type->getWrappedType(); + + while ($containerType instanceof WrappingTypeInterface) { + $containerType = $containerType->getWrappedType(); + } + + return $containerType instanceof ObjectType ? $containerType->getClassName() : null; + } + /** * Sets a value of the object using the PropertyAccess component. */ diff --git a/src/Serializer/Tests/AbstractItemNormalizerTest.php b/src/Serializer/Tests/AbstractItemNormalizerTest.php index 3a44cbd322..2f75480dca 100644 --- a/src/Serializer/Tests/AbstractItemNormalizerTest.php +++ b/src/Serializer/Tests/AbstractItemNormalizerTest.php @@ -50,6 +50,7 @@ use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; +use Symfony\Bridge\Doctrine\Serializer\Normalizer\CollectionDenormalizer; use Symfony\Component\PropertyAccess\PropertyAccessor; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\Component\Serializer\Exception\MissingConstructorArgumentsException; @@ -59,6 +60,8 @@ use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use Symfony\Component\Serializer\SerializerInterface; use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\Type\ObjectType; +use Symfony\Component\TypeInfo\TypeIdentifier; /** * @author Amrouche Hamza @@ -1242,6 +1245,68 @@ public function testDenormalizeNullableCollectionOfBackedEnums(): void $this->assertInstanceOf(Dummy::class, $actual); } + public function testDenormalizeCollectionWithAClassContainer(): void + { + $data = ['relatedDummies' => [['name' => 'foo']]]; + + $propertyNameCollectionFactory = $this->createStub(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactory->method('create')->willReturn(new PropertyNameCollection(['relatedDummies'])); + + // The property declares a Doctrine collection class and a value type that is not an API + // resource, so the element type has to travel through the context instead of the type. + $propertyMetadataFactory = $this->createStub(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactory->method('create')->willReturn( + (new ApiProperty()) + ->withNativeType(Type::collection(Type::object(ArrayCollection::class), Type::object(RelatedDummy::class), Type::int())) + ->withWritable(true) + ); + + $resourceClassResolver = $this->createStub(ResourceClassResolverInterface::class); + $resourceClassResolver->method('isResourceClass')->willReturnMap([ + [Dummy::class, true], + [RelatedDummy::class, false], + [ArrayCollection::class, false], + ]); + $resourceClassResolver->method('getResourceClass')->willReturnMap([ + [null, Dummy::class, Dummy::class], + ]); + + $relatedDummy = new RelatedDummy(); + $collection = new ArrayCollection([$relatedDummy]); + + // Symfony 8.2 and later ship the Doctrine collection denormalizer, so the declared + // collection class is handed to the Serializer with the element and key types in the + // context. Earlier versions keep the "Foo[]" type and build a plain array. + $supportsCollectionClass = class_exists(CollectionDenormalizer::class); + + $propertyAccessor = $this->createMock(PropertyAccessorInterface::class); + $propertyAccessor->expects($this->once()) + ->method('setValue') + ->with($this->isInstanceOf(Dummy::class), 'relatedDummies', $supportsCollectionClass ? $collection : [$relatedDummy]); + + $iriConverter = $this->createStub(IriConverterInterface::class); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(DenormalizerInterface::class); + + if ($supportsCollectionClass) { + $serializerProphecy->denormalize($data['relatedDummies'], ArrayCollection::class, null, Argument::that(static function (array $context): bool { + return $context['value_type'] instanceof ObjectType + && RelatedDummy::class === $context['value_type']->getClassName() + && $context['key_type']->isIdentifiedBy(TypeIdentifier::INT); + }))->willReturn($collection); + } else { + $serializerProphecy->denormalize($data['relatedDummies'], RelatedDummy::class.'[]', null, Argument::type('array'))->willReturn([$relatedDummy]); + } + + $normalizer = new class($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, null, null, [], null, null) extends AbstractItemNormalizer {}; + $normalizer->setSerializer($serializerProphecy->reveal()); + + $actual = $normalizer->denormalize($data, Dummy::class); + + $this->assertInstanceOf(Dummy::class, $actual); + } + public function testDenormalizeWrongTypedValueForNullableObjectPropertyPreservesNormalizerException(): void { // What Symfony's DateTimeNormalizer throws for a value it cannot parse.