From 6620ed4b51981c357214bd120867878f5d8e32f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anderson=20M=C3=BCller?= Date: Tue, 10 Mar 2026 13:58:10 +0100 Subject: [PATCH 1/8] Add a test for `self` constructor promoted parameter - Introduce `DummyWithSelfConstructorPromotedParameter` test class to validate the normalization/denormalization. --- .../AbstractObjectNormalizerTest.php | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/Tests/Normalizer/AbstractObjectNormalizerTest.php b/Tests/Normalizer/AbstractObjectNormalizerTest.php index f2f5d2bb493..fc8349e05ad 100644 --- a/Tests/Normalizer/AbstractObjectNormalizerTest.php +++ b/Tests/Normalizer/AbstractObjectNormalizerTest.php @@ -967,6 +967,18 @@ classMetadataFactory: new ClassMetadataFactory(new AttributeLoader()), $this->assertEquals(new DummyWithEnumUnion(EnumB::B), $serializer->denormalize($normalized, DummyWithEnumUnion::class)); } + public function testDenormalizeSelfConstructorPromotedParameter() + { + $serializer = new Serializer([ + new ObjectNormalizer( + propertyTypeExtractor: new PropertyInfoExtractor([], [new ReflectionExtractor()]), + ), + ]); + + $normalized = $serializer->normalize(new DummyWithSelfConstructorPromotedParameter('A', new DummyWithSelfConstructorPromotedParameter('B'))); + $this->assertEquals(new DummyWithSelfConstructorPromotedParameter('A', new DummyWithSelfConstructorPromotedParameter('B')), $serializer->denormalize($normalized, DummyWithSelfConstructorPromotedParameter::class)); + } + #[RequiresMethod(ReflectionTypeResolver::class, 'resolve')] public function testDenormalizeUsesConstructorUnionTypeWhenExtractorIsLessPrecise() { @@ -1954,6 +1966,15 @@ public function __construct( } } +class DummyWithSelfConstructorPromotedParameter +{ + public function __construct( + public readonly string $name, + public readonly ?self $partner = null, + ) { + } +} + class DummyWithIntOrString { public function __construct( From 2602f2801edeca04e5bcdf0660fea3037dee965a Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Tue, 10 Mar 2026 16:00:03 +0100 Subject: [PATCH 2/8] [Serializer] Fix self type resolution on promoted constructor parameters --- Normalizer/AbstractObjectNormalizer.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Normalizer/AbstractObjectNormalizer.php b/Normalizer/AbstractObjectNormalizer.php index 420fa598e10..190582777f2 100644 --- a/Normalizer/AbstractObjectNormalizer.php +++ b/Normalizer/AbstractObjectNormalizer.php @@ -43,6 +43,7 @@ use Symfony\Component\TypeInfo\Type\ObjectType; use Symfony\Component\TypeInfo\Type\UnionType; use Symfony\Component\TypeInfo\Type\WrappingTypeInterface; +use Symfony\Component\TypeInfo\TypeContext\TypeContextFactory; use Symfony\Component\TypeInfo\TypeIdentifier; use Symfony\Component\TypeInfo\TypeResolver\ReflectionTypeResolver; @@ -1010,9 +1011,10 @@ protected function denormalizeParameter(\ReflectionClass $class, \ReflectionPara $parameterType = $parameter->getType(); static $parameterTypeResolver; + static $parameterTypeContextFactory; if (null !== $parameterType && $parameterTypeResolver ??= class_exists(ReflectionTypeResolver::class) ? new ReflectionTypeResolver() : false) { - $resolvedParameterType = $parameterTypeResolver->resolve($parameterType); + $resolvedParameterType = $parameterTypeResolver->resolve($parameterType, ($parameterTypeContextFactory ??= new TypeContextFactory())->createFromClassName($class->name, $parameter->getDeclaringClass()?->name)); if ($resolvedParameterType->isSatisfiedBy(static fn (Type $t) => match (true) { $t instanceof BuiltinType && TypeIdentifier::NULL !== $t->getTypeIdentifier() => !$type->isIdentifiedBy($t->getTypeIdentifier()), $t instanceof ObjectType => !$type->isIdentifiedBy($t->getClassName()), From 508be04fb6013dfbe80c51a57df4afa7b06ee88b Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Tue, 10 Mar 2026 16:43:37 +0100 Subject: [PATCH 3/8] Fix merge --- Normalizer/AbstractObjectNormalizer.php | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/Normalizer/AbstractObjectNormalizer.php b/Normalizer/AbstractObjectNormalizer.php index 190582777f2..e0f00883046 100644 --- a/Normalizer/AbstractObjectNormalizer.php +++ b/Normalizer/AbstractObjectNormalizer.php @@ -990,15 +990,22 @@ protected function denormalizeParameter(\ReflectionClass $class, \ReflectionPara $type = [new LegacyType($parameterType->getName(), $parameter->allowsNull())]; } } else { + $parameterTypeName = match ($parameterType->getName()) { + 'self' => $parameter->getDeclaringClass()?->name ?? $class->name, + 'parent' => $parameter->getDeclaringClass()?->getParentClass()?->name ?? $parameterType->getName(), + 'static' => $class->name, + default => $parameterType->getName(), + }; + foreach ($type as $legacyType) { - if (LegacyType::BUILTIN_TYPE_OBJECT === $legacyType->getBuiltinType() && $parameterType->getName() === $legacyType->getClassName()) { + if (LegacyType::BUILTIN_TYPE_OBJECT === $legacyType->getBuiltinType() && $parameterTypeName === $legacyType->getClassName()) { $matches = true; break; } } if (!$matches) { - $type = [new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, $parameter->allowsNull(), $parameterType->getName())]; + $type = [new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, $parameter->allowsNull(), $parameterTypeName)]; } } } From d155c56878d07f28fd70e5e4ccc60ccf88d43565 Mon Sep 17 00:00:00 2001 From: "Alexander M. Turek" Date: Tue, 24 Mar 2026 14:12:05 +0100 Subject: [PATCH 4/8] Configure deprecation triggers --- phpunit.xml.dist | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 173cf616b69..02430dc3321 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,7 +1,7 @@ + + trigger_deprecation + Doctrine\Deprecations\Deprecation::trigger + Doctrine\Deprecations\Deprecation::triggerIfCalledFromOutside + ./ From 172f10a638f2cb08e3980975dd14279770026230 Mon Sep 17 00:00:00 2001 From: sn3mdev Date: Thu, 26 Mar 2026 20:31:07 +0100 Subject: [PATCH 5/8] [Serializer] Fix can*() prefix support in GetSetMethodNormalizer isGetMethod() recognizes can*() as a valid getter prefix, but isAllowedAttribute() and getAttributeValue() only support get*, is* and has*. This causes can*()-derived attributes to be silently dropped during normalization. --- CHANGELOG.md | 1 + Normalizer/GetSetMethodNormalizer.php | 9 ++- .../Normalizer/GetSetMethodNormalizerTest.php | 60 +++++++++++++++++++ 3 files changed, 68 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 69829709f06..ba5a8afd4ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ CHANGELOG 6.4 --- + * Fix `GetSetMethodNormalizer` not extracting `can*()` prefixed methods in `isAllowedAttribute()` and `getAttributeValue()` * Add `TranslatableNormalizer` * Allow `Context` attribute to target classes * Deprecate Doctrine annotations support in favor of native attributes diff --git a/Normalizer/GetSetMethodNormalizer.php b/Normalizer/GetSetMethodNormalizer.php index c8aadd4669d..79d5ce87bb0 100644 --- a/Normalizer/GetSetMethodNormalizer.php +++ b/Normalizer/GetSetMethodNormalizer.php @@ -98,7 +98,7 @@ private function supports(string $class, bool $readAttributes): bool } /** - * Checks if a method's name matches /^(get|is|has).+$/ and can be called non-statically without parameters. + * Checks if a method's name matches /^(get|is|has|can).+$/ and can be called non-statically without parameters. */ private function isGetMethod(\ReflectionMethod $method): bool { @@ -163,6 +163,11 @@ protected function getAttributeValue(object $object, string $attribute, ?string return $object->$haser(); } + $caner = 'can'.$attribute; + if (method_exists($object, $caner) && \is_callable([$object, $caner])) { + return $object->$caner(); + } + return null; } @@ -202,7 +207,7 @@ protected function isAllowedAttribute($classOrObject, string $attribute, ?string $reflection = self::$reflectionCache[$class]; if ($context['_read_attributes'] ?? true) { - foreach (['get', 'is', 'has'] as $getterPrefix) { + foreach (['get', 'is', 'has', 'can'] as $getterPrefix) { $getter = $getterPrefix.$attribute; $reflectionMethod = $reflection->hasMethod($getter) ? $reflection->getMethod($getter) : null; if ($reflectionMethod && $this->isGetMethod($reflectionMethod)) { diff --git a/Tests/Normalizer/GetSetMethodNormalizerTest.php b/Tests/Normalizer/GetSetMethodNormalizerTest.php index afc2b1f8767..74362c46661 100644 --- a/Tests/Normalizer/GetSetMethodNormalizerTest.php +++ b/Tests/Normalizer/GetSetMethodNormalizerTest.php @@ -631,6 +631,28 @@ public function testSkipVoidNeverReturnTypeAccessors() $this->assertArrayNotHasKey('neverProperty', $normalized); $this->assertEquals('value', $normalized['normalProperty']); } + + public function testNormalizeWithCanPrefixMethods() + { + $obj = new GetSetDummyWithCanMethods(); + $obj->setName('Alice'); + + $this->assertEquals( + ['name' => 'Alice', 'edit' => true, 'delete' => false], + $this->normalizer->normalize($obj, 'any') + ); + } + + public function testNormalizeWithCanPrefixOnly() + { + $obj = new GetSetDummyWithCanOnly(); + + $this->assertTrue($this->normalizer->supportsNormalization($obj)); + $this->assertEquals( + ['read' => true, 'write' => false], + $this->normalizer->normalize($obj, 'any') + ); + } } class GetSetDummy @@ -1017,3 +1039,41 @@ public function isolate() { } } + +class GetSetDummyWithCanMethods +{ + private $name; + + public function getName() + { + return $this->name; + } + + public function setName($name) + { + $this->name = $name; + } + + public function canEdit(): bool + { + return true; + } + + public function canDelete(): bool + { + return false; + } +} + +class GetSetDummyWithCanOnly +{ + public function canRead(): bool + { + return true; + } + + public function canWrite(): bool + { + return false; + } +} From 6df2763c04ff4b3b9f3d55635caf5c64f35b5eef Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Mon, 30 Mar 2026 15:17:04 +0200 Subject: [PATCH 6/8] [Serializer] Remove needless line in changelog --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba5a8afd4ef..69829709f06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,6 @@ CHANGELOG 6.4 --- - * Fix `GetSetMethodNormalizer` not extracting `can*()` prefixed methods in `isAllowedAttribute()` and `getAttributeValue()` * Add `TranslatableNormalizer` * Allow `Context` attribute to target classes * Deprecate Doctrine annotations support in favor of native attributes From 7c668b2e0cfb0decd23dd936b5893da211e5bd49 Mon Sep 17 00:00:00 2001 From: pcescon Date: Thu, 26 Mar 2026 12:12:09 +0100 Subject: [PATCH 7/8] [Serializer] Fix mixed-typed constructor parameters overriding getter-inferred type --- Normalizer/AbstractObjectNormalizer.php | 6 +- .../AbstractObjectNormalizerTest.php | 137 +++++++++++++++++- 2 files changed, 138 insertions(+), 5 deletions(-) diff --git a/Normalizer/AbstractObjectNormalizer.php b/Normalizer/AbstractObjectNormalizer.php index e0f00883046..5789b21641c 100644 --- a/Normalizer/AbstractObjectNormalizer.php +++ b/Normalizer/AbstractObjectNormalizer.php @@ -975,7 +975,7 @@ protected function denormalizeParameter(\ReflectionClass $class, \ReflectionPara // BC layer for PropertyTypeExtractorInterface::getTypes(). // Can be removed as soon as PropertyTypeExtractorInterface::getTypes() is removed (8.0). if (\is_array($type)) { - if (($parameterType = $parameter->getType()) instanceof \ReflectionNamedType) { + if (($parameterType = $parameter->getType()) instanceof \ReflectionNamedType && 'mixed' !== $parameterType->getName()) { $matches = false; if ($parameterType->isBuiltin()) { @@ -1023,7 +1023,7 @@ protected function denormalizeParameter(\ReflectionClass $class, \ReflectionPara if (null !== $parameterType && $parameterTypeResolver ??= class_exists(ReflectionTypeResolver::class) ? new ReflectionTypeResolver() : false) { $resolvedParameterType = $parameterTypeResolver->resolve($parameterType, ($parameterTypeContextFactory ??= new TypeContextFactory())->createFromClassName($class->name, $parameter->getDeclaringClass()?->name)); if ($resolvedParameterType->isSatisfiedBy(static fn (Type $t) => match (true) { - $t instanceof BuiltinType && TypeIdentifier::NULL !== $t->getTypeIdentifier() => !$type->isIdentifiedBy($t->getTypeIdentifier()), + $t instanceof BuiltinType && !\in_array($t->getTypeIdentifier(), [TypeIdentifier::NULL, TypeIdentifier::MIXED], true) => !$type->isIdentifiedBy($t->getTypeIdentifier()), $t instanceof ObjectType => !$type->isIdentifiedBy($t->getClassName()), default => false, })) { @@ -1033,7 +1033,7 @@ protected function denormalizeParameter(\ReflectionClass $class, \ReflectionPara if ($parameterType->isBuiltin()) { $typeIdentifier = TypeIdentifier::tryFrom($parameterType->getName()); - if (null !== $typeIdentifier && !$type->isIdentifiedBy($typeIdentifier)) { + if (null !== $typeIdentifier && TypeIdentifier::MIXED !== $typeIdentifier && !$type->isIdentifiedBy($typeIdentifier)) { $type = Type::builtin($typeIdentifier); } } elseif (!$type->isIdentifiedBy($parameterType->getName())) { diff --git a/Tests/Normalizer/AbstractObjectNormalizerTest.php b/Tests/Normalizer/AbstractObjectNormalizerTest.php index fc8349e05ad..651f52923fa 100644 --- a/Tests/Normalizer/AbstractObjectNormalizerTest.php +++ b/Tests/Normalizer/AbstractObjectNormalizerTest.php @@ -12,7 +12,8 @@ namespace Symfony\Component\Serializer\Tests\Normalizer; use PHPUnit\Framework\Attributes\DataProvider; -use PHPUnit\Framework\Attributes\RequiresMethod; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\IgnoreDeprecations; use PHPUnit\Framework\TestCase; use Symfony\Component\PropertyAccess\PropertyPath; use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor; @@ -979,7 +980,6 @@ public function testDenormalizeSelfConstructorPromotedParameter() $this->assertEquals(new DummyWithSelfConstructorPromotedParameter('A', new DummyWithSelfConstructorPromotedParameter('B')), $serializer->denormalize($normalized, DummyWithSelfConstructorPromotedParameter::class)); } - #[RequiresMethod(ReflectionTypeResolver::class, 'resolve')] public function testDenormalizeUsesConstructorUnionTypeWhenExtractorIsLessPrecise() { $extractor = new class implements PropertyTypeExtractorInterface { @@ -999,6 +999,118 @@ public function getTypes(string $class, string $property, array $context = []): $this->assertEquals(new DummyWithIntOrString(1), $serializer->denormalize(['value' => 1], DummyWithIntOrString::class)); } + public function testDenormalizeMixedConstructorParameterUsesExtractorType() + { + $extractor = new PropertyInfoExtractor([], [new ReflectionExtractor()]); + + $entityDenormalizer = new class implements DenormalizerInterface { + public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed + { + return new DummyEntity((int) $data); + } + + public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool + { + return DummyEntity::class === $type; + } + + public function getSupportedTypes(?string $format): array + { + return [DummyEntity::class => true]; + } + }; + + $serializer = new Serializer([ + $entityDenormalizer, + new ObjectNormalizer(propertyTypeExtractor: $extractor), + ]); + + $result = $serializer->denormalize(['entity' => 42], DummyWithMixedConstructorParamAndEntityGetter::class); + + $this->assertInstanceOf(DummyWithMixedConstructorParamAndEntityGetter::class, $result); + $this->assertInstanceOf(DummyEntity::class, $result->getEntity()); + $this->assertSame(42, $result->getEntity()->id); + } + + #[Group('legacy')] + #[IgnoreDeprecations] + public function testDenormalizeMixedConstructorParameterUsesExtractorTypeLegacy() + { + $extractor = new PropertyInfoExtractor([], [new ReflectionExtractor()]); + + $entityDenormalizer = new class implements DenormalizerInterface { + public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed + { + return new DummyEntity((int) $data); + } + + public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool + { + return DummyEntity::class === $type; + } + + public function getSupportedTypes(?string $format): array + { + return [DummyEntity::class => true]; + } + }; + + $serializer = new Serializer([ + $entityDenormalizer, + new ObjectNormalizer(propertyTypeExtractor: $extractor), + ]); + + $result = $serializer->denormalize(['entity' => 42], DummyWithMixedConstructorParamAndEntityGetter::class); + + $this->assertInstanceOf(DummyWithMixedConstructorParamAndEntityGetter::class, $result); + $this->assertInstanceOf(DummyEntity::class, $result->getEntity()); + $this->assertSame(42, $result->getEntity()->id); + } + + #[Group('legacy')] + #[IgnoreDeprecations] + public function testDenormalizeMixedConstructorParameterUsesExtractorTypeLegacyTypes() + { + $extractor = new class implements PropertyTypeExtractorInterface { + public function getTypes(string $class, string $property, array $context = []): ?array + { + if (DummyWithMixedConstructorParamAndEntityGetter::class === $class && 'entity' === $property) { + return [new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, true, DummyEntity::class)]; + } + + return null; + } + }; + + $entityDenormalizer = new class implements DenormalizerInterface { + public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed + { + return new DummyEntity((int) $data); + } + + public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool + { + return DummyEntity::class === $type; + } + + public function getSupportedTypes(?string $format): array + { + return [DummyEntity::class => true]; + } + }; + + $serializer = new Serializer([ + $entityDenormalizer, + new ObjectNormalizer(propertyTypeExtractor: $extractor), + ]); + + $result = $serializer->denormalize(['entity' => 42], DummyWithMixedConstructorParamAndEntityGetter::class); + + $this->assertInstanceOf(DummyWithMixedConstructorParamAndEntityGetter::class, $result); + $this->assertInstanceOf(DummyEntity::class, $result->getEntity()); + $this->assertSame(42, $result->getEntity()->id); + } + public function testDenormalizeWithNumberAsSerializedNameAndNoArrayReindex() { $normalizer = new AbstractObjectNormalizerWithMetadata(); @@ -2076,3 +2188,24 @@ class DummyGenericsValueWrapper /** @var T[] */ public array $values; } + +class DummyEntity +{ + public function __construct( + public int $id, + ) { + } +} + +class DummyWithMixedConstructorParamAndEntityGetter +{ + public function __construct( + private mixed $entity = null, + ) { + } + + public function getEntity(): ?DummyEntity + { + return $this->entity; + } +} From 9b8a7bac546c1a663aadbb6f833197a90115937f Mon Sep 17 00:00:00 2001 From: Mathias Arlaud Date: Mon, 30 Mar 2026 16:04:22 +0200 Subject: [PATCH 8/8] [Serializer] Fix denormalization of nested array with key types --- Normalizer/AbstractObjectNormalizer.php | 12 ++++-- Normalizer/ArrayDenormalizer.php | 11 +++++ Tests/DeserializeNestedArrayOfObjectsTest.php | 40 +++++++++++++++++++ 3 files changed, 60 insertions(+), 3 deletions(-) diff --git a/Normalizer/AbstractObjectNormalizer.php b/Normalizer/AbstractObjectNormalizer.php index b4b060a6346..54fc264e355 100644 --- a/Normalizer/AbstractObjectNormalizer.php +++ b/Normalizer/AbstractObjectNormalizer.php @@ -534,18 +534,18 @@ private function validateAndDenormalize(array $types, string $currentClass, stri $builtinType = Type::BUILTIN_TYPE_OBJECT; $class = $collectionValueType->getClassName().'[]'; - if (\count($collectionKeyType = $type->getCollectionKeyTypes()) > 0) { + if ($collectionKeyType = $type->getCollectionKeyTypes()) { $context['key_type'] = \count($collectionKeyType) > 1 ? $collectionKeyType : $collectionKeyType[0]; } $context['value_type'] = $collectionValueType; - } elseif ($type->isCollection() && \count($collectionValueType = $type->getCollectionValueTypes()) > 0 && Type::BUILTIN_TYPE_ARRAY === $collectionValueType[0]->getBuiltinType()) { + } elseif ($type->isCollection() && ($collectionValueType = $type->getCollectionValueTypes()) && Type::BUILTIN_TYPE_ARRAY === $collectionValueType[0]->getBuiltinType()) { // get inner type for any nested array [$innerType] = $collectionValueType; // note that it will break for any other builtinType $dimensions = '[]'; - while (\count($innerType->getCollectionValueTypes()) > 0 && Type::BUILTIN_TYPE_ARRAY === $innerType->getBuiltinType()) { + while ($innerType->getCollectionValueTypes() && Type::BUILTIN_TYPE_ARRAY === $innerType->getBuiltinType()) { $dimensions .= '[]'; [$innerType] = $innerType->getCollectionValueTypes(); } @@ -554,6 +554,12 @@ private function validateAndDenormalize(array $types, string $currentClass, stri // the builtinType is the inner one and the class is the class followed by []...[] $builtinType = $innerType->getBuiltinType(); $class = $innerType->getClassName().$dimensions; + + if ($collectionKeyType = $type->getCollectionKeyTypes()) { + $context['key_type'] = \count($collectionKeyType) > 1 ? $collectionKeyType : $collectionKeyType[0]; + } + + $context['value_type'] = $collectionValueType[0]; } else { // default fallback (keep it as array) $builtinType = $type->getBuiltinType(); diff --git a/Normalizer/ArrayDenormalizer.php b/Normalizer/ArrayDenormalizer.php index ad182123f8c..515ed6b70ed 100644 --- a/Normalizer/ArrayDenormalizer.php +++ b/Normalizer/ArrayDenormalizer.php @@ -62,6 +62,17 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a return $keyType->getBuiltinType(); }, \is_array($keyType = $context['key_type'] ?? []) ? $keyType : [$keyType]); + $valueType = $context['value_type'] ?? null; + if ($valueType instanceof Type && $valueType->isCollection()) { + if ($collectionKeyTypes = $valueType->getCollectionKeyTypes()) { + $context['key_type'] = \count($collectionKeyTypes) > 1 ? $collectionKeyTypes : $collectionKeyTypes[0]; + } + + if ($collectionValueTypes = $valueType->getCollectionValueTypes()) { + $context['value_type'] = $collectionValueTypes[0]; + } + } + foreach ($data as $key => $value) { $subContext = $context; $subContext['deserialization_path'] = ($context['deserialization_path'] ?? false) ? \sprintf('%s[%s]', $context['deserialization_path'], $key) : "[$key]"; diff --git a/Tests/DeserializeNestedArrayOfObjectsTest.php b/Tests/DeserializeNestedArrayOfObjectsTest.php index 57f2b568ef4..e5af4660b05 100644 --- a/Tests/DeserializeNestedArrayOfObjectsTest.php +++ b/Tests/DeserializeNestedArrayOfObjectsTest.php @@ -102,6 +102,30 @@ public function testPropertyPhpDocWithKeyTypes() self::assertArrayHasKey(3, $zoo->animalsGenerics); self::assertInstanceOf(Animal::class, $zoo->animalsGenerics[3]); } + + public function testNestedArrayWithStringKeyUnderList() + { + $json = << new JsonEncoder()]); + + /** @var BarWithNestedKeyTypes $bar */ + $bar = $serializer->deserialize($json, BarWithNestedKeyTypes::class, 'json'); + + self::assertCount(1, $bar->foos); + self::assertInstanceOf(FooWithStringKeyedList::class, $bar->foos[0]); + self::assertCount(1, $bar->foos[0]->operators); + self::assertArrayHasKey('something', $bar->foos[0]->operators); + self::assertCount(1, $bar->foos[0]->operators['something']); + self::assertInstanceOf(Animal::class, $bar->foos[0]->operators['something'][0]); + self::assertSame('Bug', $bar->foos[0]->operators['something'][0]->getName()); + } } class Zoo @@ -178,3 +202,19 @@ public function setName($name) $this->name = $name; } } + +class FooWithStringKeyedList +{ + /** @param array> $operators */ + public function __construct(public array $operators = []) + { + } +} + +class BarWithNestedKeyTypes +{ + /** @param list $foos */ + public function __construct(public array $foos = []) + { + } +}