From 71918e4644ed2f6ce1ff8514b9ee87a57f074285 Mon Sep 17 00:00:00 2001 From: wuchen90 Date: Mon, 29 Jun 2026 16:45:14 +0200 Subject: [PATCH 01/25] fix(jsonschema): respect readableLink for resource-typed properties on non-resource parents (#8362) --- .../Factory/SchemaPropertyMetadataFactory.php | 22 ++++++++++- .../SchemaPropertyMetadataFactoryTest.php | 38 ++++++++++++++++--- 2 files changed, 52 insertions(+), 8 deletions(-) diff --git a/src/JsonSchema/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php b/src/JsonSchema/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php index 6e55fa4b467..540a9230771 100644 --- a/src/JsonSchema/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php +++ b/src/JsonSchema/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php @@ -73,9 +73,27 @@ public function create(string $resourceClass, string $property, array $options = // on output the serializer embeds the relation as soon as gen_id is false, even when it is not a readable link (see AbstractItemNormalizer::normalizeRelation()) $link = $isInput ? $propertyMetadata->isWritableLink() : ($propertyMetadata->isReadableLink() || false === $propertyMetadata->getGenId()); - // on output a non-resource object is serialized by the standard object normalizer, which embeds related resources regardless of readableLink (see AbstractItemNormalizer::supportsNormalization()) + // on output a non-resource object is serialized by the standard object normalizer, which embeds non-resource properties regardless of readableLink (see AbstractItemNormalizer::supportsNormalization()) + // For resource-typed properties however, the circular reference handler (see AbstractItemNormalizer::$defaultContext) may produce an IRI, so isReadableLink should determine the schema if (!$isInput && !$this->isResourceClass($resourceClass)) { - $link = true; + if (method_exists(PropertyInfoExtractor::class, 'getType')) { + if (!$propertyMetadata->getNativeType()?->isSatisfiedBy(fn (Type $t) => $t instanceof ObjectType && $this->resourceClassResolver->isResourceClass($t->getClassName()))) { + $link = true; + } + } else { + $propertyTypeIsResource = false; + foreach ($propertyMetadata->getBuiltinTypes() ?? [] as $builtinType) { + $className = $builtinType->isCollection() ? ($builtinType->getCollectionValueTypes()[0] ?? null)?->getClassName() : $builtinType->getClassName(); + if ($className && $this->resourceClassResolver->isResourceClass($className)) { + $propertyTypeIsResource = true; + break; + } + } + + if (!$propertyTypeIsResource) { + $link = true; + } + } } $propertySchema = $propertyMetadata->getSchema() ?? []; diff --git a/src/JsonSchema/Tests/Metadata/Property/Factory/SchemaPropertyMetadataFactoryTest.php b/src/JsonSchema/Tests/Metadata/Property/Factory/SchemaPropertyMetadataFactoryTest.php index ece12edc0a4..c89fa1ecdce 100644 --- a/src/JsonSchema/Tests/Metadata/Property/Factory/SchemaPropertyMetadataFactoryTest.php +++ b/src/JsonSchema/Tests/Metadata/Property/Factory/SchemaPropertyMetadataFactoryTest.php @@ -207,11 +207,12 @@ public function testRelationWithGenIdFalseIsEmbeddedInOutputSchema(): void } /** - * A relation borne by a non-resource object (e.g. a raw Doctrine entity embedded as a readable link) - * is serialized by the standard object normalizer, which embeds the related resource regardless of its - * readableLink/genId. The output schema must embed it too, otherwise a strict client rejects the payload. + * A resource-typed relation on a non-resource parent with readableLink=false must be an iri-reference in the + * output schema. The circular reference handler (see AbstractItemNormalizer::$defaultContext) converts the related + * resource to an IRI when a cycle is detected (e.g. A → B[] → A), so isReadableLink must drive the schema + * instead of the old "non-resource parents always embed" heuristic. */ - public function testRelationOnNonResourceParentIsEmbeddedInOutputSchema(): void + public function testRelationOnNonResourceParentFollowsReadableLinkInOutputSchema(): void { if (!method_exists(PropertyInfoExtractor::class, 'getType')) { // @phpstan-ignore-line symfony/property-info 6.4 is still allowed and this may be true $this->markTestSkipped('This test only supports type-info component'); @@ -221,7 +222,7 @@ public function testRelationOnNonResourceParentIsEmbeddedInOutputSchema(): void // the parent (DummyWithEnum) is not a resource, the related class (Dummy) is $resourceClassResolver->method('isResourceClass')->willReturnCallback(static fn (string $class): bool => Dummy::class === $class); - // not a readable link, gen_id left to its default: the relation would normally be an iri-reference string + // readableLink=false: the relation should be an iri-reference string even on a non-resource parent $apiProperty = (new ApiProperty(nativeType: Type::object(Dummy::class))) ->withReadableLink(false); $decorated = $this->createMock(PropertyMetadataFactoryInterface::class); @@ -230,7 +231,32 @@ public function testRelationOnNonResourceParentIsEmbeddedInOutputSchema(): void $schemaPropertyMetadataFactory = new SchemaPropertyMetadataFactory($resourceClassResolver, $decorated); $apiProperty = $schemaPropertyMetadataFactory->create(DummyWithEnum::class, 'relatedDummy'); - // defers to SchemaFactory ($ref to embedded subschema) instead of an iri-reference string + $this->assertSame(['type' => 'string', 'format' => 'iri-reference', 'example' => 'https://example.com/'], $apiProperty->getSchema()); + } + + /** + * A relation on a non-resource parent where the property type is also not a resource must still be embedded + * (UNKNOWN_TYPE → resolved as $ref by SchemaFactory). The standard object normalizer embeds non-resource + * objects and isReadableLink is irrelevant here, so the schema defers to SchemaFactory. + */ + public function testNonResourceRelationOnNonResourceParentIsEmbeddedInOutputSchema(): void + { + if (!method_exists(PropertyInfoExtractor::class, 'getType')) { // @phpstan-ignore-line symfony/property-info 6.4 is still allowed and this may be true + $this->markTestSkipped('This test only supports type-info component'); + } + + $resourceClassResolver = $this->createMock(ResourceClassResolverInterface::class); + // neither the parent nor the property type is a resource + $resourceClassResolver->method('isResourceClass')->willReturn(false); + + $apiProperty = (new ApiProperty(nativeType: Type::object(DummyWithEnum::class))) + ->withReadableLink(false); + $decorated = $this->createMock(PropertyMetadataFactoryInterface::class); + $decorated->expects($this->once())->method('create')->with(DummyWithEnum::class, 'nested')->willReturn($apiProperty); + + $schemaPropertyMetadataFactory = new SchemaPropertyMetadataFactory($resourceClassResolver, $decorated); + $apiProperty = $schemaPropertyMetadataFactory->create(DummyWithEnum::class, 'nested'); + $this->assertSame(['type' => Schema::UNKNOWN_TYPE], $apiProperty->getSchema()); } From 66829859fac0fe0363b8a91fd338d3ab71877774 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Mon, 29 Jun 2026 16:46:06 +0200 Subject: [PATCH 02/25] fix(doctrine): support filtering scalar enum columns by IRI (#8358) Closes #8336 --- src/Doctrine/Odm/Filter/IriFilter.php | 22 +++++ src/Doctrine/Orm/Filter/IriFilter.php | 54 ++++++++++- .../Document/IriFilterScalarEnum/Game.php | 55 +++++++++++ .../Entity/IriFilterScalarEnum/Game.php | 57 +++++++++++ .../Parameters/IriFilterScalarEnumTest.php | 97 +++++++++++++++++++ 5 files changed, 283 insertions(+), 2 deletions(-) create mode 100644 tests/Fixtures/TestBundle/Document/IriFilterScalarEnum/Game.php create mode 100644 tests/Fixtures/TestBundle/Entity/IriFilterScalarEnum/Game.php create mode 100644 tests/Functional/Parameters/IriFilterScalarEnumTest.php diff --git a/src/Doctrine/Odm/Filter/IriFilter.php b/src/Doctrine/Odm/Filter/IriFilter.php index 26290e09b56..0464e631e75 100644 --- a/src/Doctrine/Odm/Filter/IriFilter.php +++ b/src/Doctrine/Odm/Filter/IriFilter.php @@ -25,6 +25,7 @@ use ApiPlatform\State\ParameterProvider\IriConverterParameterProvider; use Doctrine\ODM\MongoDB\Aggregation\Builder; use Doctrine\ODM\MongoDB\DocumentManager; +use Doctrine\ODM\MongoDB\Mapping\ClassMetadata; use Doctrine\ODM\MongoDB\Mapping\MappingException; /** @@ -68,7 +69,23 @@ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Opera $leafProperty = $nestedInfo['leaf_property'] ?? $property; $classMetadata = $documentManager->getClassMetadata($leafClass); + // Backed enum exposed as a resource: match the scalar field against the resolved enum. if (!$classMetadata->hasReference($leafProperty)) { + if (!$this->isEnumField($classMetadata, $leafProperty)) { + return; + } + + $normalize = static fn (mixed $v): mixed => $v instanceof \BackedEnum ? $v->value : $v; + + if (is_iterable($value)) { + $values = \is_array($value) ? $value : iterator_to_array($value); + $match->{$operator}($aggregationBuilder->matchExpr()->field($matchField)->in(array_map($normalize, $values))); + + return; + } + + $match->{$operator}($aggregationBuilder->matchExpr()->field($matchField)->equals($normalize($value))); + return; } @@ -113,4 +130,9 @@ public static function getParameterProvider(): string { return IriConverterParameterProvider::class; } + + private function isEnumField(ClassMetadata $classMetadata, string $field): bool + { + return $classMetadata->hasField($field) && null !== ($classMetadata->getFieldMapping($field)['enumType'] ?? null); + } } diff --git a/src/Doctrine/Orm/Filter/IriFilter.php b/src/Doctrine/Orm/Filter/IriFilter.php index 4cac01d21b6..e8de32ce793 100644 --- a/src/Doctrine/Orm/Filter/IriFilter.php +++ b/src/Doctrine/Orm/Filter/IriFilter.php @@ -22,6 +22,8 @@ use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\ParameterProviderFilterInterface; use ApiPlatform\State\ParameterProvider\IriConverterParameterProvider; +use Doctrine\ORM\Mapping\ClassMetadata; +use Doctrine\ORM\Mapping\FieldMapping; use Doctrine\ORM\QueryBuilder; /** @@ -57,6 +59,13 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q $ormLeafMetadata = $this->resolveLeafMetadataAtRuntime($queryBuilder, $resourceClass, $parameter->getProperty(), $property); } + // Backed enum exposed as a resource: compare the scalar column to the resolved enum. + if (null === $ormLeafMetadata) { + $this->applyEnumComparison($queryBuilder, $alias, $property, $parameterName, $value, $context); + + return; + } + // Collection associations (OneToMany/ManyToMany) require a JOIN to compare individual elements. if ($ormLeafMetadata['is_collection_valued']) { $queryBuilder->join(\sprintf('%s.%s', $alias, $property), $parameterName); @@ -102,6 +111,25 @@ public static function getParameterProvider(): string return IriConverterParameterProvider::class; } + private function applyEnumComparison(QueryBuilder $queryBuilder, string $alias, string $property, string $parameterName, mixed $value, array $context): void + { + $propertyExpr = \sprintf('%s.%s', $alias, $property); + $normalize = static fn (mixed $v): mixed => $v instanceof \BackedEnum ? $v->value : $v; + + if (is_iterable($value)) { + $values = \is_array($value) ? $value : iterator_to_array($value); + $queryBuilder + ->{$context['whereClause'] ?? 'andWhere'}(\sprintf('%s IN (:%s)', $propertyExpr, $parameterName)) + ->setParameter($parameterName, array_map($normalize, $values)); + + return; + } + + $queryBuilder + ->{$context['whereClause'] ?? 'andWhere'}(\sprintf('%s = :%s', $propertyExpr, $parameterName)) + ->setParameter($parameterName, $normalize($value)); + } + /** * @return array{is_collection_valued: bool, association_target_class: string, identifier_type: ?string}|null */ @@ -122,9 +150,12 @@ private function getOrmLeafMetadata(mixed $parameter): ?array * Resolves leaf metadata at runtime by walking the association chain. * Used as fallback when precomputed orm_leaf_metadata is not available. * - * @return array{is_collection_valued: bool, association_target_class: string, identifier_type: ?string} + * Returns null when the leaf is a backed enum column so the caller compares it directly; + * a non-association, non-enum leaf is left to raise a mapping error. + * + * @return array{is_collection_valued: bool, association_target_class: string, identifier_type: ?string}|null */ - private function resolveLeafMetadataAtRuntime(QueryBuilder $queryBuilder, string $resourceClass, string $originalProperty, string $leafProperty): array + private function resolveLeafMetadataAtRuntime(QueryBuilder $queryBuilder, string $resourceClass, string $originalProperty, string $leafProperty): ?array { $em = $queryBuilder->getEntityManager(); $metadata = $em->getClassMetadata($resourceClass); @@ -135,6 +166,10 @@ private function resolveLeafMetadataAtRuntime(QueryBuilder $queryBuilder, string $metadata = $em->getClassMetadata($associationMapping['targetEntity']); } + if (!$metadata->hasAssociation($leafProperty) && $this->isEnumField($metadata, $leafProperty)) { + return null; + } + $isCollectionValued = $metadata->isCollectionValuedAssociation($leafProperty); $associationMapping = $metadata->getAssociationMapping($leafProperty); $targetClass = $associationMapping['targetEntity']; @@ -152,4 +187,19 @@ private function resolveLeafMetadataAtRuntime(QueryBuilder $queryBuilder, string 'identifier_type' => $identifierType, ]; } + + private function isEnumField(ClassMetadata $metadata, string $field): bool + { + if (!isset($metadata->fieldMappings[$field])) { + return false; + } + + $fieldMapping = $metadata->fieldMappings[$field]; + // Doctrine ORM 2.x returns an array and Doctrine ORM 3.x returns a FieldMapping object. + if ($fieldMapping instanceof FieldMapping) { + $fieldMapping = (array) $fieldMapping; + } + + return null !== ($fieldMapping['enumType'] ?? null); + } } diff --git a/tests/Fixtures/TestBundle/Document/IriFilterScalarEnum/Game.php b/tests/Fixtures/TestBundle/Document/IriFilterScalarEnum/Game.php new file mode 100644 index 00000000000..1fcc69cba1e --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/IriFilterScalarEnum/Game.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Document\IriFilterScalarEnum; + +use ApiPlatform\Doctrine\Odm\Filter\IriFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use ApiPlatform\Tests\Fixtures\TestBundle\Enum\GamePlayMode; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +#[ODM\Document] +#[ApiResource( + shortName: 'IriFilterScalarEnumGame', + operations: [ + new GetCollection( + normalizationContext: ['hydra_prefix' => false], + parameters: [ + 'playMode' => new QueryParameter(filter: new IriFilter()), + ], + ), + new Get(), + ] +)] +class Game +{ + #[ODM\Id(strategy: 'INCREMENT', type: 'int')] + private ?int $id = null; + + #[ODM\Field(type: 'string')] + public string $name; + + // Scalar field backed by an enum that is itself exposed as an API resource: + // the enum is never a Doctrine reference, so IriFilter must resolve the IRI + // to the enum case and match the scalar field against its backing value. + #[ODM\Field(type: 'string', enumType: GamePlayMode::class)] + public GamePlayMode $playMode = GamePlayMode::SINGLE_PLAYER; + + public function getId(): ?int + { + return $this->id; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/IriFilterScalarEnum/Game.php b/tests/Fixtures/TestBundle/Entity/IriFilterScalarEnum/Game.php new file mode 100644 index 00000000000..d582a780df4 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/IriFilterScalarEnum/Game.php @@ -0,0 +1,57 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity\IriFilterScalarEnum; + +use ApiPlatform\Doctrine\Orm\Filter\IriFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use ApiPlatform\Tests\Fixtures\TestBundle\Enum\GamePlayMode; +use Doctrine\ORM\Mapping as ORM; + +#[ORM\Entity] +#[ApiResource( + shortName: 'IriFilterScalarEnumGame', + operations: [ + new GetCollection( + normalizationContext: ['hydra_prefix' => false], + parameters: [ + 'playMode' => new QueryParameter(filter: new IriFilter()), + ], + ), + new Get(), + ] +)] +class Game +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column(type: 'integer')] + private ?int $id = null; + + #[ORM\Column(type: 'string', length: 255)] + public string $name; + + // Scalar column backed by an enum that is itself exposed as an API resource: + // the enum can never be a Doctrine association, so IriFilter must resolve the + // IRI to the enum case and compare the scalar column to its backing value. + #[ORM\Column(type: 'string', enumType: GamePlayMode::class)] + public GamePlayMode $playMode = GamePlayMode::SINGLE_PLAYER; + + public function getId(): ?int + { + return $this->id; + } +} diff --git a/tests/Functional/Parameters/IriFilterScalarEnumTest.php b/tests/Functional/Parameters/IriFilterScalarEnumTest.php new file mode 100644 index 00000000000..34ddcc14f08 --- /dev/null +++ b/tests/Functional/Parameters/IriFilterScalarEnumTest.php @@ -0,0 +1,97 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional\Parameters; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\IriFilterScalarEnum\Game as DocumentGame; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\IriFilterScalarEnum\Game; +use ApiPlatform\Tests\Fixtures\TestBundle\Enum\GamePlayMode; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Doctrine\ODM\MongoDB\MongoDBException; + +final class IriFilterScalarEnumTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [Game::class, GamePlayMode::class]; + } + + public function testFilterScalarEnumColumnByIri(): void + { + $client = $this->createClient(); + $res = $client->request('GET', '/iri_filter_scalar_enum_games?playMode=/game_play_modes/SINGLE_PLAYER')->toArray(); + + $this->assertCount(2, $res['member']); + foreach ($res['member'] as $game) { + $this->assertSame('/game_play_modes/SINGLE_PLAYER', $game['playMode']); + } + } + + public function testFilterScalarEnumColumnByIriMultiple(): void + { + $client = $this->createClient(); + $res = $client->request('GET', '/iri_filter_scalar_enum_games?playMode[]=/game_play_modes/SINGLE_PLAYER&playMode[]=/game_play_modes/CO_OP')->toArray(); + + $this->assertCount(3, $res['member']); + } + + public function testFilterScalarEnumColumnByUnknownIriYieldsNoResult(): void + { + $client = $this->createClient(); + $res = $client->request('GET', '/iri_filter_scalar_enum_games?playMode=/game_play_modes/MULTI_PLAYER')->toArray(); + + $this->assertCount(0, $res['member']); + } + + /** + * @throws \Throwable + */ + protected function setUp(): void + { + $this->recreateSchema([$this->isMongoDB() ? DocumentGame::class : Game::class]); + $this->loadFixtures(); + } + + /** + * @throws \Throwable + * @throws MongoDBException + */ + private function loadFixtures(): void + { + $manager = $this->getManager(); + $class = $this->isMongoDB() ? DocumentGame::class : Game::class; + + foreach ([ + ['Solo Quest', GamePlayMode::SINGLE_PLAYER], + ['Lone Wolf', GamePlayMode::SINGLE_PLAYER], + ['Team Up', GamePlayMode::CO_OP], + ] as [$name, $playMode]) { + $game = new $class(); + $game->name = $name; + $game->playMode = $playMode; + $manager->persist($game); + } + + $manager->flush(); + } +} From f1f33cac9a15331bc940654d37bf9fd9828d0c28 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Mon, 29 Jun 2026 17:06:52 +0200 Subject: [PATCH 03/25] fix(ci): link monorepo root in laravel e2e so siblings resolve from working tree (#8363) --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d0094de6cc7..bc2c6df9758 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1187,7 +1187,7 @@ jobs: - name: Install api-platform/laravel run: | composer require laravel/framework:"^13.0" --no-update - composer global link ../src/Laravel --permanent + composer global link ../ --working-directory=$(pwd) --permanent composer config minimum-stability dev composer config prefer-stable true composer require api-platform/laravel:"@dev" -W From 43e3b4cf716ed7b0d3962b5e022d065a2bb746c9 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Tue, 30 Jun 2026 08:35:12 +0200 Subject: [PATCH 04/25] fix(openapi): don't apply the global name converter to the generated document (#8360) --- .../Bundle/Resources/config/openapi.php | 5 +- tests/Functional/OpenApiNameConverterTest.php | 90 +++++++++++++++++++ 2 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 tests/Functional/OpenApiNameConverterTest.php diff --git a/src/Symfony/Bundle/Resources/config/openapi.php b/src/Symfony/Bundle/Resources/config/openapi.php index 8e51f6418a4..a45f051f9fc 100644 --- a/src/Symfony/Bundle/Resources/config/openapi.php +++ b/src/Symfony/Bundle/Resources/config/openapi.php @@ -29,8 +29,11 @@ return function (ContainerConfigurator $container) { $services = $container->services(); + $services->set('api_platform.openapi.name_converter') + ->parent('serializer.name_converter.metadata_aware.abstract'); + $services->set('api_platform.openapi.normalizer', OpenApiNormalizer::class) - ->args([inline_service(Serializer::class)->arg(0, [inline_service(ObjectNormalizer::class)->arg(0, service('serializer.mapping.class_metadata_factory'))->arg(1, service('serializer.name_converter.metadata_aware'))->arg(2, service('api_platform.property_accessor'))->arg(3, service('api_platform.property_info'))])->arg(1, [service('serializer.encoder.json')])]) + ->args([inline_service(Serializer::class)->arg(0, [inline_service(ObjectNormalizer::class)->arg(0, service('serializer.mapping.class_metadata_factory'))->arg(1, service('api_platform.openapi.name_converter'))->arg(2, service('api_platform.property_accessor'))->arg(3, service('api_platform.property_info'))])->arg(1, [service('serializer.encoder.json')])]) ->tag('serializer.normalizer', ['priority' => -795]); $services->alias(OpenApiNormalizer::class, 'api_platform.openapi.normalizer'); diff --git a/tests/Functional/OpenApiNameConverterTest.php b/tests/Functional/OpenApiNameConverterTest.php new file mode 100644 index 00000000000..2ff0828ca32 --- /dev/null +++ b/tests/Functional/OpenApiNameConverterTest.php @@ -0,0 +1,90 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue8143\ReferenceResponse; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Symfony\Component\Config\Loader\LoaderInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; + +class OpenApiNameConverterAppKernel extends \AppKernel +{ + public function getCacheDir(): string + { + return parent::getCacheDir().'/openapi_name_converter'; + } + + public function getLogDir(): string + { + return parent::getLogDir().'/openapi_name_converter'; + } + + protected function configureContainer(ContainerBuilder $c, LoaderInterface $loader): void + { + parent::configureContainer($c, $loader); + + $loader->load(static function (ContainerBuilder $container): void { + $container->loadFromExtension('framework', [ + 'serializer' => [ + 'name_converter' => 'serializer.name_converter.camel_case_to_snake_case', + ], + ]); + }); + } +} + +final class OpenApiNameConverterTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = true; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ReferenceResponse::class]; + } + + protected static function getKernelClass(): string + { + return OpenApiNameConverterAppKernel::class; + } + + public function testGlobalNameConverterDoesNotLeakIntoOpenApiDocument(): void + { + $response = self::createClient()->request('GET', '/docs', [ + 'headers' => ['Accept' => 'application/vnd.openapi+json'], + ]); + + $this->assertResponseIsSuccessful(); + $json = $response->toArray(); + $content = $response->getContent(); + + // OpenAPI keys must stay camelCase even when a global name converter is configured. + $this->assertStringContainsString('"operationId"', $content); + $this->assertStringNotContainsString('"operation_id"', $content); + $this->assertStringNotContainsString('"extension_properties"', $content); + $this->assertStringNotContainsString('"external_docs"', $content); + $this->assertStringNotContainsString('"request_bodies"', $content); + $this->assertStringNotContainsString('"security_schemes"', $content); + + // The #[SerializedName('$ref')] metadata must still be honored. + $responses = $json['paths']['/issue8143_reference_response']['post']['responses']; + $this->assertArrayHasKey('$ref', $responses['401']); + $this->assertSame('#/components/responses/401', $responses['401']['$ref']); + } +} From fe84af2365244055737df73f888f7aa3888f462e Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Tue, 30 Jun 2026 08:35:38 +0200 Subject: [PATCH 05/25] fix(symfony): allow null $data in PlaceholderAction (#8359) Closes #8354 --- src/Symfony/EventListener/DeserializeListener.php | 4 ++++ tests/Functional/JsonLd/InputOutputDtoTest.php | 4 ---- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Symfony/EventListener/DeserializeListener.php b/src/Symfony/EventListener/DeserializeListener.php index bf34cea24ca..0a00de57633 100644 --- a/src/Symfony/EventListener/DeserializeListener.php +++ b/src/Symfony/EventListener/DeserializeListener.php @@ -76,6 +76,10 @@ public function onKernelRequest(RequestEvent $event): void } if (!$operation->canDeserialize()) { + if (!$request->attributes->has('data')) { + $request->attributes->set('data', null); + } + return; } diff --git a/tests/Functional/JsonLd/InputOutputDtoTest.php b/tests/Functional/JsonLd/InputOutputDtoTest.php index e52474d0f82..45c40db9b26 100644 --- a/tests/Functional/JsonLd/InputOutputDtoTest.php +++ b/tests/Functional/JsonLd/InputOutputDtoTest.php @@ -158,10 +158,6 @@ public function testInputOutputCycle(): void public function testCreateNoInputResource(): void { - if ($_SERVER['USE_SYMFONY_LISTENERS'] ?? false) { - $this->markTestSkipped('PlaceholderAction cannot resolve $data when input:false in event-listener mode.'); - } - $response = self::createClient()->request('POST', '/jsonld_no_inputs', [ 'headers' => ['Accept' => 'application/ld+json'], ]); From a9c1b538cb48ee5e629ed3909ce56653970c8889 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20Ostroluck=C3=BD?= Date: Thu, 2 Jul 2026 13:05:59 +0200 Subject: [PATCH 06/25] fix: clone Parameters before mutation in security and validator providers (#8378) --- src/State/Provider/SecurityParameterProvider.php | 3 ++- src/State/Tests/Provider/SecurityParameterProviderTest.php | 5 ++++- src/Symfony/Validator/State/ParameterValidatorProvider.php | 3 ++- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/State/Provider/SecurityParameterProvider.php b/src/State/Provider/SecurityParameterProvider.php index 238908b2362..301c2d5cb36 100644 --- a/src/State/Provider/SecurityParameterProvider.php +++ b/src/State/Provider/SecurityParameterProvider.php @@ -52,7 +52,8 @@ public function provide(Operation $operation, array $uriVariables = [], array $c $operation = $request?->attributes->get('_api_operation') ?? $operation; - $parameters = $operation->getParameters() ?? new Parameters(); + $existingParameters = $operation->getParameters(); + $parameters = $existingParameters ? clone $existingParameters : new Parameters(); if ($operation instanceof HttpOperation) { foreach ($operation->getUriVariables() ?? [] as $key => $uriVariable) { diff --git a/src/State/Tests/Provider/SecurityParameterProviderTest.php b/src/State/Tests/Provider/SecurityParameterProviderTest.php index ee7bbae8710..bccc26be323 100644 --- a/src/State/Tests/Provider/SecurityParameterProviderTest.php +++ b/src/State/Tests/Provider/SecurityParameterProviderTest.php @@ -15,6 +15,7 @@ use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\Link; +use ApiPlatform\Metadata\Parameters; use ApiPlatform\Metadata\ResourceAccessCheckerInterface; use ApiPlatform\State\Provider\SecurityParameterProvider; use ApiPlatform\State\ProviderInterface; @@ -28,9 +29,10 @@ public function testIsGrantedLink(): void { $obj = new \stdClass(); $barObj = new \stdClass(); + $sharedParams = new Parameters(); $operation = new GetCollection(uriVariables: [ 'barId' => new Link(toProperty: 'bar', fromClass: 'Bar', security: 'is_granted("some_voter", "bar")'), - ], class: \stdClass::class); + ], class: \stdClass::class, parameters: $sharedParams); $decorated = $this->createMock(ProviderInterface::class); $decorated->method('provide')->willReturn($obj); $request = new Request(attributes: ['bar' => $barObj]); @@ -38,6 +40,7 @@ public function testIsGrantedLink(): void $resourceAccessChecker->expects($this->once())->method('isGranted')->with('Bar', 'is_granted("some_voter", "bar")', ['object' => $obj, 'previous_object' => null, 'request' => $request, 'bar' => $barObj, 'barId' => 1, 'operation' => $operation])->willReturn(true); $accessChecker = new SecurityParameterProvider($decorated, $resourceAccessChecker); $accessChecker->provide($operation, ['barId' => 1], ['request' => $request]); + $this->assertCount(0, $sharedParams, 'provide() must not mutate the Parameters object stored on the cached operation'); } public function testIsNotGrantedLink(): void diff --git a/src/Symfony/Validator/State/ParameterValidatorProvider.php b/src/Symfony/Validator/State/ParameterValidatorProvider.php index 62af5b22c28..b814f378dd8 100644 --- a/src/Symfony/Validator/State/ParameterValidatorProvider.php +++ b/src/Symfony/Validator/State/ParameterValidatorProvider.php @@ -52,7 +52,8 @@ public function provide(Operation $operation, array $uriVariables = [], array $c } $constraintViolationList = new ConstraintViolationList(); - $parameters = $operation->getParameters() ?? new Parameters(); + $existingParameters = $operation->getParameters(); + $parameters = $existingParameters ? clone $existingParameters : new Parameters(); if ($operation instanceof HttpOperation) { foreach ($operation->getUriVariables() ?? [] as $key => $uriVariable) { From f9d3706bb583f3a7dd74be1cc94729e49cc0611c Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 3 Jul 2026 06:48:00 +0200 Subject: [PATCH 07/25] fix(mcp): make tools/list resilient to an empty registry (#8371) * fix(mcp): make tools/list resilient to an empty registry tools/list reads from the SDK registry, which is populated once, when mcp.server is built. Under a persistent runtime (e.g. FrankenPHP worker mode) that single build can capture an empty registry and stay empty for the whole process, so tools/list returns [] while tools/call keeps working through the request-time Handler. Add a ListHandler (tagged mcp.request_handler, so it precedes the SDK's registry-backed list handlers) that loads API Platform elements into the registry on first use and reads back through the shared registry, so runtime registrations and registry decorators are preserved. Refs #8370 * Apply suggestions from code review Co-authored-by: Antoine Bluchet --- src/Mcp/Server/ListHandler.php | 82 ++++++++++ src/Mcp/Tests/Server/ListHandlerTest.php | 140 ++++++++++++++++++ .../Bundle/Resources/config/mcp/mcp.php | 13 ++ 3 files changed, 235 insertions(+) create mode 100644 src/Mcp/Server/ListHandler.php create mode 100644 src/Mcp/Tests/Server/ListHandlerTest.php diff --git a/src/Mcp/Server/ListHandler.php b/src/Mcp/Server/ListHandler.php new file mode 100644 index 00000000000..3ac710dc8d1 --- /dev/null +++ b/src/Mcp/Server/ListHandler.php @@ -0,0 +1,82 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Mcp\Server; + +use Mcp\Capability\Registry\Loader\LoaderInterface; +use Mcp\Capability\RegistryInterface; +use Mcp\Schema\JsonRpc\Request; +use Mcp\Schema\JsonRpc\Response; +use Mcp\Schema\Request\ListResourcesRequest; +use Mcp\Schema\Request\ListToolsRequest; +use Mcp\Schema\Result\ListResourcesResult; +use Mcp\Schema\Result\ListToolsResult; +use Mcp\Server\Handler\Request\RequestHandlerInterface; +use Mcp\Server\Session\SessionInterface; + +/** + * Serves tools/list and resources/list from the MCP registry, loading API Platform elements + * into it on first use. + * + * The SDK populates the registry once, when mcp.server is built. Under a persistent runtime + * (e.g. FrankenPHP worker mode) that single build can capture an empty registry (cold metadata + * cache) and stays empty for the whole process, so tools/list returns [] while tools/call keeps + * working through the request-time {@see Handler}. Loading the API Platform elements lazily here + * heals that: it runs once per process (registrations are idempotent by name) and reads back + * through the shared registry, so runtime registrations and registry decorators are preserved. + * + * Tagged mcp.request_handler, it takes precedence over the SDK's registry-backed list handlers. + * + * @experimental + * TODO: remove once php-sdk:^0.7 has https://github.com/modelcontextprotocol/php-sdk/pull/389/changes + * + * @implements RequestHandlerInterface + */ +final class ListHandler implements RequestHandlerInterface +{ + private bool $loaded = false; + + public function __construct( + private readonly RegistryInterface $registry, + private readonly LoaderInterface $loader, + private readonly int $pageSize = 20, + ) { + } + + public function supports(Request $request): bool + { + return $request instanceof ListToolsRequest || $request instanceof ListResourcesRequest; + } + + /** + * @return Response + */ + public function handle(Request $request, SessionInterface $session): Response + { + if (!$this->loaded) { + $this->loader->load($this->registry); + $this->loaded = true; + } + + if ($request instanceof ListResourcesRequest) { + $page = $this->registry->getResources($this->pageSize, $request->cursor); + $result = new ListResourcesResult($page->references, $page->nextCursor); + } else { + \assert($request instanceof ListToolsRequest); + $page = $this->registry->getTools($this->pageSize, $request->cursor); + $result = new ListToolsResult($page->references, $page->nextCursor); + } + + return new Response($request->getId(), $result); + } +} diff --git a/src/Mcp/Tests/Server/ListHandlerTest.php b/src/Mcp/Tests/Server/ListHandlerTest.php new file mode 100644 index 00000000000..a8bfd4bdf4a --- /dev/null +++ b/src/Mcp/Tests/Server/ListHandlerTest.php @@ -0,0 +1,140 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Mcp\Tests\Server; + +use ApiPlatform\JsonSchema\Schema; +use ApiPlatform\JsonSchema\SchemaFactoryInterface; +use ApiPlatform\Mcp\Capability\Registry\Loader; +use ApiPlatform\Mcp\Server\ListHandler; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\McpResource; +use ApiPlatform\Metadata\McpTool; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; +use ApiPlatform\Metadata\Resource\ResourceNameCollection; +use Mcp\Capability\Registry; +use Mcp\Capability\Registry\Loader\LoaderInterface; +use Mcp\Capability\RegistryInterface; +use Mcp\Schema\Request\ListResourcesRequest; +use Mcp\Schema\Request\ListToolsRequest; +use Mcp\Schema\Result\ListResourcesResult; +use Mcp\Schema\Result\ListToolsResult; +use Mcp\Schema\Tool; +use Mcp\Server\Session\SessionInterface; +use PHPUnit\Framework\TestCase; + +class ListHandlerTest extends TestCase +{ + public function testListToolsLoadsApiPlatformElementsIntoTheRegistry(): void + { + $inputSchema = new Schema(Schema::VERSION_JSON_SCHEMA); + unset($inputSchema['$schema']); + $inputSchema['type'] = 'object'; + $inputSchema['properties'] = ['query' => ['type' => 'string']]; + + $schemaFactory = $this->createMock(SchemaFactoryInterface::class); + $schemaFactory->method('buildSchema')->willReturn($inputSchema); + + $mcpTool = new McpTool( + name: 'search', + description: 'Search things', + structuredContent: false, + class: \stdClass::class, + ); + + $resource = (new ApiResource(class: \stdClass::class))->withMcp(['search' => $mcpTool]); + + $registry = new Registry(); + $handler = new ListHandler($registry, $this->createLoader($resource, $schemaFactory)); + + $result = $handler->handle((new ListToolsRequest())->withId(1), $this->createMock(SessionInterface::class))->result; + + $this->assertInstanceOf(ListToolsResult::class, $result); + $this->assertCount(1, $result->tools); + $this->assertSame('search', $result->tools[0]->name); + } + + public function testListResourcesLoadsApiPlatformElementsIntoTheRegistry(): void + { + $mcpResource = new McpResource( + uri: 'dummy://docs', + name: 'docs', + description: 'Documentation resource', + mimeType: 'text/plain', + class: \stdClass::class, + ); + + $resource = (new ApiResource(class: \stdClass::class))->withMcp(['docs' => $mcpResource]); + + $registry = new Registry(); + $handler = new ListHandler($registry, $this->createLoader($resource, $this->createMock(SchemaFactoryInterface::class))); + + $result = $handler->handle((new ListResourcesRequest())->withId(1), $this->createMock(SessionInterface::class))->result; + + $this->assertInstanceOf(ListResourcesResult::class, $result); + $this->assertCount(1, $result->resources); + $this->assertSame('dummy://docs', $result->resources[0]->uri); + } + + /** + * Reading through the shared registry (rather than a private one) keeps tools registered at + * runtime — e.g. dynamically discovered affordances — visible in tools/list. + */ + public function testListToolsIncludesToolsRegisteredAtRuntime(): void + { + $registry = new Registry(); + $registry->registerTool(new Tool(name: 'runtime_tool', title: null, inputSchema: ['type' => 'object', 'properties' => [], 'required' => null], description: null, annotations: null), 'runtime_handler'); + + $loader = $this->createMock(LoaderInterface::class); + $handler = new ListHandler($registry, $loader); + + $result = $handler->handle((new ListToolsRequest())->withId(1), $this->createMock(SessionInterface::class))->result; + + $names = array_map(static fn (Tool $t): string => $t->name, $result->tools); + $this->assertContains('runtime_tool', $names); + } + + public function testElementsAreLoadedOncePerProcess(): void + { + $registry = $this->createMock(RegistryInterface::class); + $registry->method('getTools')->willReturn(new \Mcp\Schema\Page([], null)); + + $loader = $this->createMock(LoaderInterface::class); + $loader->expects($this->once())->method('load'); + + $handler = new ListHandler($registry, $loader); + $handler->handle((new ListToolsRequest())->withId(1), $this->createMock(SessionInterface::class)); + $handler->handle((new ListToolsRequest())->withId(2), $this->createMock(SessionInterface::class)); + } + + public function testSupportsListRequests(): void + { + $handler = new ListHandler($this->createMock(RegistryInterface::class), $this->createMock(LoaderInterface::class)); + + $this->assertTrue($handler->supports(new ListToolsRequest())); + $this->assertTrue($handler->supports(new ListResourcesRequest())); + } + + private function createLoader(ApiResource $resource, SchemaFactoryInterface $schemaFactory): Loader + { + $nameCollectionFactory = $this->createMock(ResourceNameCollectionFactoryInterface::class); + $nameCollectionFactory->method('create')->willReturn(new ResourceNameCollection([\stdClass::class])); + + $metadataCollectionFactory = $this->createMock(ResourceMetadataCollectionFactoryInterface::class); + $metadataCollectionFactory->method('create')->willReturn(new ResourceMetadataCollection(\stdClass::class, [$resource])); + + return new Loader($nameCollectionFactory, $metadataCollectionFactory, $schemaFactory); + } +} diff --git a/src/Symfony/Bundle/Resources/config/mcp/mcp.php b/src/Symfony/Bundle/Resources/config/mcp/mcp.php index 97e042661b3..4740dde1f38 100644 --- a/src/Symfony/Bundle/Resources/config/mcp/mcp.php +++ b/src/Symfony/Bundle/Resources/config/mcp/mcp.php @@ -17,6 +17,7 @@ use ApiPlatform\Mcp\JsonSchema\SchemaFactory; use ApiPlatform\Mcp\Metadata\Operation\Factory\OperationMetadataFactory; use ApiPlatform\Mcp\Routing\IriConverter; +use ApiPlatform\Mcp\Server\ListHandler; use ApiPlatform\Mcp\State\ToolProvider; return static function (ContainerConfigurator $container) { @@ -35,6 +36,18 @@ ]) ->tag('mcp.loader'); + // Serves tools/list and resources/list, loading API Platform elements into the registry on + // first use. This heals a persistent runtime (e.g. FrankenPHP worker mode) where the SDK + // builds the registry once and may capture an empty state. Reads back through the shared + // registry so runtime registrations and decorators are preserved. Takes precedence over the + // SDK's registry-backed list handlers. + $services->set('api_platform.mcp.list_handler', ListHandler::class) + ->args([ + service('mcp.registry'), + service('api_platform.mcp.loader'), + ]) + ->tag('mcp.request_handler'); + $services->set('api_platform.mcp.iri_converter', IriConverter::class) ->decorate('api_platform.iri_converter', null, 300) ->args([ From 48e8f97816a945d205b0cfaf4c76cc532c143312 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 3 Jul 2026 07:41:53 +0200 Subject: [PATCH 08/25] fix(doctrine): use PHP property name in DQL for modern filters with name converter (#8382) Fixes #8380 --- .../Odm/NestedPropertyHelperTrait.php | 2 +- .../Orm/NestedPropertyHelperTrait.php | 2 +- ...meterResourceMetadataCollectionFactory.php | 5 +- .../Document/ConvertedFilterParameter.php | 57 +++++++++++++ .../Entity/ConvertedFilterParameter.php | 59 +++++++++++++ .../NameConverterModernFilterTest.php | 84 +++++++++++++++++++ 6 files changed, 206 insertions(+), 3 deletions(-) create mode 100644 tests/Fixtures/TestBundle/Document/ConvertedFilterParameter.php create mode 100644 tests/Fixtures/TestBundle/Entity/ConvertedFilterParameter.php create mode 100644 tests/Functional/Parameters/NameConverterModernFilterTest.php diff --git a/src/Doctrine/Odm/NestedPropertyHelperTrait.php b/src/Doctrine/Odm/NestedPropertyHelperTrait.php index e713382bece..d3a92ecdf0b 100644 --- a/src/Doctrine/Odm/NestedPropertyHelperTrait.php +++ b/src/Doctrine/Odm/NestedPropertyHelperTrait.php @@ -39,7 +39,7 @@ protected function addNestedParameterLookups(string $property, Builder $aggregat $nestedInfo = ($extraProperties['nested_properties_info'] ?? []) ? reset($extraProperties['nested_properties_info']) : null; if (!$nestedInfo) { - return $property; + return $extraProperties['query_property'] ?? $property; } $odmSegments = $nestedInfo['odm_segments'] ?? []; diff --git a/src/Doctrine/Orm/NestedPropertyHelperTrait.php b/src/Doctrine/Orm/NestedPropertyHelperTrait.php index e93f17d46f8..f8f0e1e52e0 100644 --- a/src/Doctrine/Orm/NestedPropertyHelperTrait.php +++ b/src/Doctrine/Orm/NestedPropertyHelperTrait.php @@ -43,7 +43,7 @@ protected function addNestedParameterJoins( $nestedInfo = ($extraProperties['nested_properties_info'] ?? []) ? reset($extraProperties['nested_properties_info']) : null; if (!$nestedInfo) { - return [$alias, $property]; + return [$alias, $extraProperties['query_property'] ?? $property]; } $relationClasses = $nestedInfo['relation_classes'] ?? []; diff --git a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php index f55734196e5..3972b1e8978 100644 --- a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php @@ -405,7 +405,10 @@ private function setDefaults(string $key, Parameter $parameter, ?object $filter, // Skip name conversion if we already have nested property info $paramExtraProps = $parameter->getExtraProperties(); if (!isset($paramExtraProps['nested_properties_info'])) { - $parameter = $parameter->withProperty($this->nameConverter->normalize($property)); + // Keep the original (non name-converted) property to build the query while the public property matches the API naming convention. + $parameter = $parameter + ->withExtraProperties([...$paramExtraProps, 'query_property' => $property]) + ->withProperty($this->nameConverter->normalize($property)); } } diff --git a/tests/Fixtures/TestBundle/Document/ConvertedFilterParameter.php b/tests/Fixtures/TestBundle/Document/ConvertedFilterParameter.php new file mode 100644 index 00000000000..9a44bc34e0a --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/ConvertedFilterParameter.php @@ -0,0 +1,57 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Document; + +use ApiPlatform\Doctrine\Odm\Filter\ExactFilter; +use ApiPlatform\Doctrine\Odm\Filter\SortFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +/** + * Tests that modern parameter filters (ExactFilter, SortFilter) work with + * QueryParameter on a multi-word property when a nameConverter is configured + * (issue #8380). + */ +#[ApiResource( + operations: [ + new GetCollection( + parameters: [ + 'nameConverted' => new QueryParameter( + filter: new ExactFilter(), + property: 'nameConverted', + ), + 'orderNameConverted' => new QueryParameter( + filter: new SortFilter(), + property: 'nameConverted', + ), + ], + ), + ], +)] +#[ODM\Document] +class ConvertedFilterParameter +{ + #[ODM\Id(strategy: 'INCREMENT', type: 'int')] + private ?int $id = null; + + #[ODM\Field(type: 'string')] + public string $nameConverted = ''; + + public function getId(): ?int + { + return $this->id; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/ConvertedFilterParameter.php b/tests/Fixtures/TestBundle/Entity/ConvertedFilterParameter.php new file mode 100644 index 00000000000..302a2c63e10 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/ConvertedFilterParameter.php @@ -0,0 +1,59 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Doctrine\Orm\Filter\ExactFilter; +use ApiPlatform\Doctrine\Orm\Filter\SortFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\ORM\Mapping as ORM; + +/** + * Tests that modern parameter filters (ExactFilter, SortFilter) work with + * QueryParameter on a multi-word property when a nameConverter is configured + * (issue #8380). + */ +#[ApiResource( + operations: [ + new GetCollection( + parameters: [ + 'nameConverted' => new QueryParameter( + filter: new ExactFilter(), + property: 'nameConverted', + ), + 'orderNameConverted' => new QueryParameter( + filter: new SortFilter(), + property: 'nameConverted', + ), + ], + ), + ], +)] +#[ORM\Entity] +class ConvertedFilterParameter +{ + #[ORM\Column(type: 'integer', nullable: true)] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + private ?int $id = null; + + #[ORM\Column(type: 'string')] + public string $nameConverted = ''; + + public function getId(): ?int + { + return $this->id; + } +} diff --git a/tests/Functional/Parameters/NameConverterModernFilterTest.php b/tests/Functional/Parameters/NameConverterModernFilterTest.php new file mode 100644 index 00000000000..a2f3c939141 --- /dev/null +++ b/tests/Functional/Parameters/NameConverterModernFilterTest.php @@ -0,0 +1,84 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional\Parameters; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\ConvertedFilterParameter as ConvertedFilterParameterDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ConvertedFilterParameter; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +/** + * Tests that modern parameter filters (ExactFilter, SortFilter) work with + * QueryParameter on a multi-word property when a nameConverter is configured. + * + * @see https://github.com/api-platform/core/issues/8380 + */ +final class NameConverterModernFilterTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ConvertedFilterParameter::class]; + } + + protected function setUp(): void + { + $entityClass = $this->isMongoDB() ? ConvertedFilterParameterDocument::class : ConvertedFilterParameter::class; + + $this->recreateSchema([$entityClass]); + $this->loadFixtures($entityClass); + } + + public function testExactFilterWithNameConverter(): void + { + $response = self::createClient()->request('GET', '/converted_filter_parameters?nameConverted=bar'); + $this->assertResponseIsSuccessful(); + $members = $response->toArray()['hydra:member']; + $this->assertCount(1, $members); + $this->assertSame('bar', $members[0]['name_converted']); + } + + public function testSortFilterWithNameConverter(): void + { + $response = self::createClient()->request('GET', '/converted_filter_parameters?orderNameConverted=desc'); + $this->assertResponseIsSuccessful(); + $members = $response->toArray()['hydra:member']; + $names = array_map(static fn (array $m): string => $m['name_converted'], $members); + $this->assertSame(['foo', 'baz', 'bar'], $names); + } + + /** + * @param class-string $entityClass + */ + private function loadFixtures(string $entityClass): void + { + $manager = $this->getManager(); + + foreach (['bar', 'baz', 'foo'] as $name) { + $entity = new $entityClass(); + $entity->nameConverted = $name; + $manager->persist($entity); + } + + $manager->flush(); + } +} From 798b4cc53dee7ef1e8dd8042668bbf9f8086b746 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 3 Jul 2026 07:42:16 +0200 Subject: [PATCH 09/25] fix(serializer): denormalize nullable collections of enums/objects (#8381) Fixes #8379 --- src/Serializer/AbstractItemNormalizer.php | 12 ++++- .../Tests/AbstractItemNormalizerTest.php | 52 +++++++++++++++++++ .../Fixtures/ApiResource/NotificationType.php | 20 +++++++ 3 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 src/Serializer/Tests/Fixtures/ApiResource/NotificationType.php diff --git a/src/Serializer/AbstractItemNormalizer.php b/src/Serializer/AbstractItemNormalizer.php index 50f2984ce28..43a8ac5e079 100644 --- a/src/Serializer/AbstractItemNormalizer.php +++ b/src/Serializer/AbstractItemNormalizer.php @@ -1299,11 +1299,19 @@ private function createAndValidateAttributeValue(string $attribute, mixed $value } } + // A nullable collection value type (e.g. an array of a nullable BackedEnum/object) is represented + // as a NullableType wrapping the real ObjectType, so it must be unwrapped before the instanceof + // check below; nested CollectionType wrapping is left untouched to keep union/collection detection intact. + $unwrappedCollectionValueType = $collectionValueType; + while ($unwrappedCollectionValueType instanceof WrappingTypeInterface && !$unwrappedCollectionValueType instanceof CollectionType) { + $unwrappedCollectionValueType = $unwrappedCollectionValueType->getWrappedType(); + } + if ( - ($t instanceof CollectionType && $collectionValueType instanceof ObjectType) + ($t instanceof CollectionType && $unwrappedCollectionValueType instanceof ObjectType) || ($t instanceof LegacyType && $t->isCollection() && null !== $collectionValueType && null !== $collectionValueType->getClassName()) ) { - $className = $collectionValueType->getClassName(); + $className = $unwrappedCollectionValueType instanceof ObjectType ? $unwrappedCollectionValueType->getClassName() : $collectionValueType->getClassName(); if (!$this->serializer instanceof DenormalizerInterface) { throw new LogicException(\sprintf('The injected serializer must be an instance of "%s".', DenormalizerInterface::class)); } diff --git a/src/Serializer/Tests/AbstractItemNormalizerTest.php b/src/Serializer/Tests/AbstractItemNormalizerTest.php index 5bdebb37817..e09e9f05a48 100644 --- a/src/Serializer/Tests/AbstractItemNormalizerTest.php +++ b/src/Serializer/Tests/AbstractItemNormalizerTest.php @@ -40,6 +40,7 @@ use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\DummyWithMultipleRequiredConstructorArgs; use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\DummyWithNullableConstructorArg; use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\NonCloneableDummy; +use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\NotificationType; use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\PropertyCollectionIriOnly; use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\PropertyCollectionIriOnlyRelation; use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\RelatedDummy; @@ -1335,6 +1336,57 @@ public function testUnionTypeCollectionDenormalizationAcceptsAnyMember(): void $this->assertInstanceOf(Dummy::class, $actual); } + public function testDenormalizeNullableCollectionOfBackedEnums(): void + { + // Nullable collection value types (NullableType wrapping ObjectType/BackedEnumType) only exist + // in the native TypeInfo system. + if (!method_exists(PropertyInfoExtractor::class, 'getType')) { + $this->markTestSkipped('Requires symfony/property-info >= 7.1 (native types).'); + } + + $data = ['notificationType' => ['email']]; + + $propertyNameCollectionFactory = $this->createStub(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactory->method('create')->willReturn(new PropertyNameCollection(['notificationType'])); + + // Mirrors what Symfony\Bridge\Doctrine\PropertyInfo\DoctrineExtractor produces for a nullable + // Doctrine SIMPLE_ARRAY column mapped with "enumType": both the collection and its value type + // end up wrapped in a NullableType. + $propertyMetadataFactory = $this->createStub(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactory->method('create')->willReturn( + (new ApiProperty()) + ->withNativeType(Type::nullable(Type::list(Type::nullable(Type::enum(NotificationType::class))))) + ->withWritable(true) + ); + + $resourceClassResolver = $this->createStub(ResourceClassResolverInterface::class); + $resourceClassResolver->method('isResourceClass')->willReturnMap([ + [Dummy::class, true], + [NotificationType::class, false], + ]); + $resourceClassResolver->method('getResourceClass')->willReturnMap([ + [null, Dummy::class, Dummy::class], + ]); + + $propertyAccessor = $this->createMock(PropertyAccessorInterface::class); + $propertyAccessor->expects($this->once()) + ->method('setValue') + ->with($this->isInstanceOf(Dummy::class), 'notificationType', [NotificationType::Email]); + + $iriConverter = $this->createStub(IriConverterInterface::class); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(DenormalizerInterface::class); + $serializerProphecy->denormalize(['email'], NotificationType::class.'[]', null, Argument::type('array'))->willReturn([NotificationType::Email]); + + $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 testTypeConfusionGuardPreservesPathAndExpectedType(): void { $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); diff --git a/src/Serializer/Tests/Fixtures/ApiResource/NotificationType.php b/src/Serializer/Tests/Fixtures/ApiResource/NotificationType.php new file mode 100644 index 00000000000..279299ac4d4 --- /dev/null +++ b/src/Serializer/Tests/Fixtures/ApiResource/NotificationType.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Serializer\Tests\Fixtures\ApiResource; + +enum NotificationType: string +{ + case Email = 'email'; + case Sms = 'sms'; +} From 340e982c22ebc6c4cf45a6f481151f668722eaf9 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 3 Jul 2026 07:49:32 +0200 Subject: [PATCH 10/25] fix(test): remove stale phpstan ignores in CollectionNormalizerTest (#8384) --- src/Hal/Tests/Serializer/CollectionNormalizerTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Hal/Tests/Serializer/CollectionNormalizerTest.php b/src/Hal/Tests/Serializer/CollectionNormalizerTest.php index 73095b1cd60..1f5bc63e3f6 100644 --- a/src/Hal/Tests/Serializer/CollectionNormalizerTest.php +++ b/src/Hal/Tests/Serializer/CollectionNormalizerTest.php @@ -125,8 +125,8 @@ private function normalizePaginator(bool $partial = false): array $paginator->method('current')->willReturn('foo'); // @phpstan-ignore-line if (!$partial) { - $paginator->method('getLastPage')->willReturn(7.); // @phpstan-ignore-line - $paginator->method('getTotalItems')->willReturn(1312.); // @phpstan-ignore-line + $paginator->method('getLastPage')->willReturn(7.); + $paginator->method('getTotalItems')->willReturn(1312.); } else { $paginator->method('count')->willReturn(12); } From 328cb67ceda3d934d8985fb0f7e08542f59fb38d Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 3 Jul 2026 08:30:28 +0200 Subject: [PATCH 11/25] fix(symfony): don't expose entrypoint in openapi format (#8383) Fixes #8361 --- src/Metadata/Util/ContentNegotiationTrait.php | 4 +- .../ApiPlatformExtension.php | 5 + .../Resources/config/symfony/controller.php | 2 +- .../Resources/config/symfony/events.php | 2 +- tests/Functional/EntrypointFormatTest.php | 125 ++++++++++++++++++ tests/Functional/OpenApiTest.php | 22 +-- 6 files changed, 148 insertions(+), 12 deletions(-) create mode 100644 tests/Functional/EntrypointFormatTest.php diff --git a/src/Metadata/Util/ContentNegotiationTrait.php b/src/Metadata/Util/ContentNegotiationTrait.php index 9f951261a4e..4f8cb15f650 100644 --- a/src/Metadata/Util/ContentNegotiationTrait.php +++ b/src/Metadata/Util/ContentNegotiationTrait.php @@ -119,12 +119,12 @@ private function getRequestFormat(Request $request, array $formats, bool $throw if (null !== $requestFormat) { $mimeType = $request->getMimeType($requestFormat); - if (isset($flattenedMimeTypes[$mimeType])) { + if (null !== $mimeType && isset($flattenedMimeTypes[$mimeType])) { return $requestFormat; } if ($throw) { - throw $this->getNotAcceptableHttpException($mimeType, $flattenedMimeTypes); + throw $this->getNotAcceptableHttpException($mimeType ?? $requestFormat, $flattenedMimeTypes); } } diff --git a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index 14410c5ff01..9b9019b368c 100644 --- a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -365,6 +365,11 @@ private function registerCommonConfiguration(ContainerBuilder $container, array $container->setParameter('api_platform.patch_formats', $patchFormats); $container->setParameter('api_platform.error_formats', $errorFormats); $container->setParameter('api_platform.docs_formats', $docsFormats); + // The entrypoint only has normalizers for hypermedia formats (jsonld, jsonhal, jsonapi) and a + // dedicated Swagger UI code path for html. Other documentation formats (e.g. openapi, yamlopenapi) + // have no Entrypoint normalizer and must not be advertised, otherwise content negotiation lets them + // through and the Symfony ObjectNormalizer fallback leaks the internal ResourceNameCollection FQCNs. + $container->setParameter('api_platform.entrypoint_formats', array_intersect_key($docsFormats, array_flip(['jsonld', 'jsonhal', 'jsonapi', 'html']))); $container->setParameter('api_platform.jsonschema_formats', []); $container->setParameter('api_platform.eager_loading.enabled', $this->isConfigEnabled($container, $config['eager_loading'])); $container->setParameter('api_platform.eager_loading.max_joins', $config['eager_loading']['max_joins']); diff --git a/src/Symfony/Bundle/Resources/config/symfony/controller.php b/src/Symfony/Bundle/Resources/config/symfony/controller.php index 2ac24e613e0..3bbed6d6106 100644 --- a/src/Symfony/Bundle/Resources/config/symfony/controller.php +++ b/src/Symfony/Bundle/Resources/config/symfony/controller.php @@ -36,7 +36,7 @@ service('api_platform.metadata.resource.name_collection_factory'), service('api_platform.state_provider.main'), service('api_platform.state_processor.main'), - '%api_platform.docs_formats%', + '%api_platform.entrypoint_formats%', ]); $services->set('api_platform.action.documentation', DocumentationAction::class) diff --git a/src/Symfony/Bundle/Resources/config/symfony/events.php b/src/Symfony/Bundle/Resources/config/symfony/events.php index c8c2c833e70..91b784f07eb 100644 --- a/src/Symfony/Bundle/Resources/config/symfony/events.php +++ b/src/Symfony/Bundle/Resources/config/symfony/events.php @@ -184,7 +184,7 @@ service('api_platform.metadata.resource.name_collection_factory'), service('api_platform.state_provider.documentation'), service('api_platform.state_processor.documentation'), - '%api_platform.docs_formats%', + '%api_platform.entrypoint_formats%', ]); $services->set('api_platform.action.documentation', DocumentationAction::class) diff --git a/tests/Functional/EntrypointFormatTest.php b/tests/Functional/EntrypointFormatTest.php new file mode 100644 index 00000000000..b1ac4df6780 --- /dev/null +++ b/tests/Functional/EntrypointFormatTest.php @@ -0,0 +1,125 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\MultipleResourceBook; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +/** + * The entrypoint must only expose hypermedia formats that have a dedicated + * EntrypointNormalizer (jsonld, jsonhal, jsonapi). Documentation formats + * (openapi, html) have no such normalizer and must not be served, otherwise + * the Symfony ObjectNormalizer fallback dumps the public ResourceNameCollection, + * leaking internal PHP FQCNs. + * + * @see https://github.com/api-platform/core/issues/8361 + */ +final class EntrypointFormatTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [MultipleResourceBook::class]; + } + + public function testEntrypointRejectsOpenApiAcceptHeader(): void + { + $response = self::createClient()->request('GET', '/', [ + 'headers' => ['accept' => 'application/vnd.openapi+json'], + ]); + + $this->assertResponseStatusCodeSame(406); + } + + /** + * The ".jsonopenapi"/".yamlopenapi" URL suffixes are resolved by routing before + * content negotiation runs, so an unsupported route format yields a 404 + * (consistent with any other resource requested with an unsupported format + * suffix), not a 406. + */ + public function testEntrypointRejectsOpenApiFormatSuffix(): void + { + $response = self::createClient()->request('GET', '/index.jsonopenapi', [ + 'headers' => ['accept' => 'application/vnd.openapi+json'], + ]); + + $this->assertResponseStatusCodeSame(404); + } + + public function testEntrypointRejectsYamlOpenApiFormatSuffix(): void + { + $response = self::createClient()->request('GET', '/index.yamlopenapi', [ + 'headers' => ['accept' => 'application/vnd.openapi+yaml'], + ]); + + $this->assertResponseStatusCodeSame(404); + } + + public function testEntrypointStillServesHtmlAsSwaggerUi(): void + { + $response = self::createClient()->request('GET', '/index.html', [ + 'headers' => ['accept' => 'text/html'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertStringContainsString('swagger-ui', $response->getContent()); + } + + public function testEntrypointStillServesJsonLd(): void + { + $response = self::createClient()->request('GET', '/index.jsonld', [ + 'headers' => ['accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $data = $response->toArray(); + $this->assertArrayHasKey('multipleResourceBook2', $data); + $this->assertEquals('/multi_route_books', $data['multipleResourceBook2']); + $this->assertStringNotContainsString('resourceNameCollection', $response->getContent()); + } + + public function testEntrypointStillServesJsonHal(): void + { + $response = self::createClient()->request('GET', '/index.jsonhal', [ + 'headers' => ['accept' => 'application/hal+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('content-type', 'application/hal+json; charset=utf-8'); + $data = $response->toArray(); + $this->assertArrayHasKey('_links', $data); + $this->assertArrayHasKey('multipleResourceBook2', $data['_links']); + } + + public function testEntrypointStillServesJsonApi(): void + { + $response = self::createClient()->request('GET', '/index.jsonapi', [ + 'headers' => ['accept' => 'application/vnd.api+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('content-type', 'application/vnd.api+json; charset=utf-8'); + $data = $response->toArray(); + $this->assertArrayHasKey('links', $data); + $this->assertArrayHasKey('multipleResourceBook2', $data['links']); + } +} diff --git a/tests/Functional/OpenApiTest.php b/tests/Functional/OpenApiTest.php index 8cb8120b63d..1a59307a44f 100644 --- a/tests/Functional/OpenApiTest.php +++ b/tests/Functional/OpenApiTest.php @@ -686,24 +686,30 @@ public function testOpenApiUiIsEnabledForDocsEndpointWithDummyObject(): void $this->assertResponseIsSuccessful(); } - public function testRetrieveTheEntrypoint(): void + /** + * @see https://github.com/api-platform/core/issues/8361 + */ + public function testEntrypointRejectsOpenApiFormat(): void { $response = self::createClient()->request('GET', '/', [ 'headers' => ['Accept' => 'application/vnd.openapi+json'], ]); - $this->assertResponseIsSuccessful(); - $this->assertResponseHeaderSame('content-type', 'application/vnd.openapi+json; charset=utf-8'); - $this->assertJson($response->getContent()); + $this->assertResponseStatusCodeSame(406); } - public function testRetrieveTheEntrypointWithUrlFormat(): void + /** + * The ".jsonopenapi" URL suffix is resolved by routing before content negotiation + * runs, so an unsupported route format yields a 404 (consistent with any other + * resource requested with an unsupported format suffix), not a 406. + * + * @see https://github.com/api-platform/core/issues/8361 + */ + public function testEntrypointRejectsOpenApiFormatWithUrlFormat(): void { $response = self::createClient()->request('GET', '/index.jsonopenapi', [ 'headers' => ['Accept' => 'application/vnd.openapi+json'], ]); - $this->assertResponseIsSuccessful(); - $this->assertResponseHeaderSame('content-type', 'application/vnd.openapi+json; charset=utf-8'); - $this->assertJson($response->getContent()); + $this->assertResponseStatusCodeSame(404); } public function testOpenApiSchemaWithNormalizationAttributes(): void From 95dd0232dd50e982d37f0590cb35ca3cefd13efa Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 3 Jul 2026 08:59:33 +0200 Subject: [PATCH 12/25] doc: changelog 4.3.16 --- CHANGELOG.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 42b34ea86c0..3a392a1a116 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## v4.3.16 + +### Bug fixes + +* [328cb67ce](https://github.com/api-platform/core/commit/328cb67ceda3d934d8985fb0f7e08542f59fb38d) fix(symfony): don't expose entrypoint in openapi format (#8383) +* [340e982c2](https://github.com/api-platform/core/commit/340e982c22ebc6c4cf45a6f481151f668722eaf9) fix(test): remove stale phpstan ignores in CollectionNormalizerTest (#8384) +* [43e3b4cf7](https://github.com/api-platform/core/commit/43e3b4cf716ed7b0d3962b5e022d065a2bb746c9) fix(openapi): don't apply the global name converter to the generated document (#8360) +* [48e8f9781](https://github.com/api-platform/core/commit/48e8f97816a945d205b0cfaf4c76cc532c143312) fix(doctrine): use PHP property name in DQL for modern filters with name converter (#8382) +* [66829859f](https://github.com/api-platform/core/commit/66829859fac0fe0363b8a91fd338d3ab71877774) fix(doctrine): support filtering scalar enum columns by IRI (#8358) +* [71918e464](https://github.com/api-platform/core/commit/71918e4644ed2f6ce1ff8514b9ee87a57f074285) fix(jsonschema): respect readableLink for resource-typed properties on non-resource parents (#8362) +* [798b4cc53](https://github.com/api-platform/core/commit/798b4cc53dee7ef1e8dd8042668bbf9f8086b746) fix(serializer): denormalize nullable collections of enums/objects (#8381) +* [a9c1b538c](https://github.com/api-platform/core/commit/a9c1b538cb48ee5e629ed3909ce56653970c8889) fix: clone Parameters before mutation in security and validator providers (#8378) +* [f1f33cac9](https://github.com/api-platform/core/commit/f1f33cac9a15331bc940654d37bf9fd9828d0c28) fix(ci): link monorepo root in laravel e2e so siblings resolve from working tree (#8363) +* [f9d3706bb](https://github.com/api-platform/core/commit/f9d3706bb583f3a7dd74be1cc94729e49cc0611c) fix(mcp): make tools/list resilient to an empty registry (#8371) +* [fe84af236](https://github.com/api-platform/core/commit/fe84af2365244055737df73f888f7aa3888f462e) fix(symfony): allow null $data in PlaceholderAction (#8359) + ## v4.3.15 ### Bug fixes From 56de59737c53e18120ed0b9b6c40633c109d58c2 Mon Sep 17 00:00:00 2001 From: Ben Davies Date: Wed, 8 Jul 2026 07:49:21 +0100 Subject: [PATCH 13/25] fix(doctrine): fetch_data=false reference for stateOptions resources (#8387) --- src/Doctrine/Orm/State/ItemProvider.php | 6 +- .../Orm/Tests/Fixtures/Model/EmployeeApi.php | 31 ++++++++ .../Orm/Tests/State/ItemProviderTest.php | 79 +++++++++++++++++++ .../FetchDataFalseStateOptions.php | 36 +++++++++ ...etchDataFalseStateOptionsItemExtension.php | 37 +++++++++ .../FetchDataFalseStateOptionsEntity.php | 36 +++++++++ tests/Fixtures/app/config/config_common.yml | 4 + ...FetchDataFalseStateOptionsResourceTest.php | 73 +++++++++++++++++ 8 files changed, 300 insertions(+), 2 deletions(-) create mode 100644 src/Doctrine/Orm/Tests/Fixtures/Model/EmployeeApi.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/FetchDataFalseStateOptions.php create mode 100644 tests/Fixtures/TestBundle/Doctrine/Orm/FetchDataFalseStateOptionsItemExtension.php create mode 100644 tests/Fixtures/TestBundle/Entity/FetchDataFalseStateOptionsEntity.php create mode 100644 tests/Functional/Doctrine/FetchDataFalseStateOptionsResourceTest.php diff --git a/src/Doctrine/Orm/State/ItemProvider.php b/src/Doctrine/Orm/State/ItemProvider.php index 536f6bb611f..369996d4c9c 100644 --- a/src/Doctrine/Orm/State/ItemProvider.php +++ b/src/Doctrine/Orm/State/ItemProvider.php @@ -109,8 +109,10 @@ private function getReferenceIdentifiers(EntityManagerInterface $manager, string $identifiers = []; foreach ($this->getLinks($entityClass, $operation, $context) as $parameterName => $link) { - // Mirrors LinksHandlerTrait: the identifier-self link has no relation property and points to the entity itself. - if ($entityClass !== $link->getFromClass() || $link->getFromProperty() || $link->getToProperty()) { + // Mirrors LinksHandlerTrait: the identifier-self link has no relation property and points to the resource itself. + // Links are expressed in resource terms, so the identifier-self link's fromClass is the resource class + // ($operation->getClass()), which differs from $entityClass for a DTO/stateOptions resource. + if ($operation->getClass() !== $link->getFromClass() || $link->getFromProperty() || $link->getToProperty()) { continue; } diff --git a/src/Doctrine/Orm/Tests/Fixtures/Model/EmployeeApi.php b/src/Doctrine/Orm/Tests/Fixtures/Model/EmployeeApi.php new file mode 100644 index 00000000000..c5008dbd047 --- /dev/null +++ b/src/Doctrine/Orm/Tests/Fixtures/Model/EmployeeApi.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Doctrine\Orm\Tests\Fixtures\Model; + +use ApiPlatform\Doctrine\Orm\State\Options; +use ApiPlatform\Doctrine\Orm\Tests\Fixtures\Entity\Employee; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; + +/** + * DTO resource backed by the {@see Employee} entity through stateOptions: the resource + * class differs from the Doctrine entity class, which is what exercises the + * fetch_data=false getReference() fast-path for DTO/stateOptions resources. + */ +#[ApiResource(stateOptions: new Options(entityClass: Employee::class))] +#[Get] +class EmployeeApi +{ + public ?int $id = null; +} diff --git a/src/Doctrine/Orm/Tests/State/ItemProviderTest.php b/src/Doctrine/Orm/Tests/State/ItemProviderTest.php index 8fbb549636e..4eba41322d7 100644 --- a/src/Doctrine/Orm/Tests/State/ItemProviderTest.php +++ b/src/Doctrine/Orm/Tests/State/ItemProviderTest.php @@ -20,6 +20,7 @@ use ApiPlatform\Doctrine\Orm\Tests\Fixtures\Entity\Company; use ApiPlatform\Doctrine\Orm\Tests\Fixtures\Entity\Employee; use ApiPlatform\Doctrine\Orm\Tests\Fixtures\Entity\OperationResource; +use ApiPlatform\Doctrine\Orm\Tests\Fixtures\Model\EmployeeApi; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; use ApiPlatform\Metadata\Exception\RuntimeException; use ApiPlatform\Metadata\Get; @@ -278,6 +279,84 @@ public function testGetItemWithFetchDataFalseFallsBackToQueryWhenIdentifierIsNot $this->assertSame($returnObject, $dataProvider->provide($operation, ['uuid' => '61817181-0ecc-42fb-a6e7-d97f2ddcb344'], ['fetch_data' => false])); } + public function testGetItemWithFetchDataFalseReturnsReferenceForStateOptionsResource(): void + { + $reference = new Employee(); + + $classMetadataMock = $this->createMock(ClassMetadata::class); + $classMetadataMock->method('getIdentifierFieldNames')->willReturn(['id']); + + $managerMock = $this->createMock(EntityManagerInterface::class); + $managerMock->method('getClassMetadata')->with(Employee::class)->willReturn($classMetadataMock); + $managerMock->expects($this->once()) + ->method('getReference') + ->with(Employee::class, ['id' => 2]) + ->willReturn($reference); + + $managerRegistryMock = $this->createMock(ManagerRegistry::class); + $managerRegistryMock->method('getManagerForClass')->with(Employee::class)->willReturn($managerMock); + + // DTO resource: the operation class (EmployeeApi) differs from the stateOptions entity class (Employee), + // so the identifier-self link's fromClass is the resource class, not the entity class. + $operation = (new Get())->withUriVariables([ + 'id' => (new Link())->withFromClass(EmployeeApi::class)->withIdentifiers(['id']), + ])->withStateOptions(new Options(entityClass: Employee::class))->withName('get')->withClass(EmployeeApi::class); + + // The reference fast-path must return before the item query is built, so item extensions never run. + $extensionMock = $this->createMock(QueryItemExtensionInterface::class); + $extensionMock->expects($this->never())->method('applyToItem'); + + $dataProvider = new ItemProvider( + $this->createStub(ResourceMetadataCollectionFactoryInterface::class), + $managerRegistryMock, + [$extensionMock] + ); + + $this->assertSame($reference, $dataProvider->provide($operation, ['id' => 2], ['fetch_data' => false])); + } + + public function testGetItemWithFetchDataFalseFallsBackToQueryForStateOptionsResourceWhenIdentifierIsNotADoctrineIdentifier(): void + { + $returnObject = new \stdClass(); + + $queryMock = $this->createMock($this->getQueryClass()); + $queryMock->method('getOneOrNullResult')->willReturn($returnObject); + + $queryBuilderMock = $this->createMock(QueryBuilder::class); + $queryBuilderMock->method('getQuery')->willReturn($queryMock); + $queryBuilderMock->method('getRootAliases')->willReturn(['o']); + + // Even for a DTO resource, the entity-identifier guard holds: the resource exposes "uuid" as its + // API identifier while the Doctrine identifier is "id", so getReference() cannot be built and we + // must fall back to the link-resolving query. + $classMetadataMock = $this->createMock(ClassMetadata::class); + $classMetadataMock->method('getIdentifierFieldNames')->willReturn(['id']); + + $repositoryMock = $this->createMock(EntityRepository::class); + $repositoryMock->method('createQueryBuilder')->with('o')->willReturn($queryBuilderMock); + + $managerMock = $this->createMock(EntityManagerInterface::class); + $managerMock->method('getClassMetadata')->willReturn($classMetadataMock); + $managerMock->method('getRepository')->willReturn($repositoryMock); + $managerMock->expects($this->never())->method('getReference'); + + $managerRegistryMock = $this->createMock(ManagerRegistry::class); + $managerRegistryMock->method('getManagerForClass')->willReturn($managerMock); + + // A no-op handleLinks mirrors what DoctrineOrmResourceCollectionMetadataFactory injects for a + // stateOptions resource in production; the unit test bypasses that factory (see CollectionProviderTest). + $operation = (new Get())->withUriVariables([ + 'uuid' => (new Link())->withFromClass(EmployeeApi::class)->withIdentifiers(['uuid'])->withParameterName('uuid'), + ])->withStateOptions(new Options(entityClass: Employee::class, handleLinks: static fn () => null))->withName('get')->withClass(EmployeeApi::class); + + $dataProvider = new ItemProvider( + $this->createStub(ResourceMetadataCollectionFactoryInterface::class), + $managerRegistryMock, + ); + + $this->assertSame($returnObject, $dataProvider->provide($operation, ['uuid' => '61817181-0ecc-42fb-a6e7-d97f2ddcb344'], ['fetch_data' => false])); + } + public function testQueryResultExtension(): void { $returnObject = new \stdClass(); diff --git a/tests/Fixtures/TestBundle/ApiResource/FetchDataFalseStateOptions.php b/tests/Fixtures/TestBundle/ApiResource/FetchDataFalseStateOptions.php new file mode 100644 index 00000000000..20aa8cfe0ec --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/FetchDataFalseStateOptions.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource; + +use ApiPlatform\Doctrine\Orm\State\Options; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FetchDataFalseStateOptionsEntity; + +/** + * DTO resource whose class differs from its Doctrine entity: the identifier link's fromClass is this + * resource class, so getReferenceIdentifiers() must compare against it (not the entity class) for the + * fetch_data=false reference fast-path to trigger. + */ +#[ApiResource( + shortName: 'FetchDataFalseStateOptions', + operations: [new Get()], + stateOptions: new Options(entityClass: FetchDataFalseStateOptionsEntity::class), +)] +class FetchDataFalseStateOptions +{ + public ?int $id = null; + + public ?string $name = null; +} diff --git a/tests/Fixtures/TestBundle/Doctrine/Orm/FetchDataFalseStateOptionsItemExtension.php b/tests/Fixtures/TestBundle/Doctrine/Orm/FetchDataFalseStateOptionsItemExtension.php new file mode 100644 index 00000000000..2a49737067a --- /dev/null +++ b/tests/Fixtures/TestBundle/Doctrine/Orm/FetchDataFalseStateOptionsItemExtension.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Doctrine\Orm; + +use ApiPlatform\Doctrine\Orm\Extension\QueryItemExtensionInterface; +use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FetchDataFalseStateOptionsEntity; +use Doctrine\ORM\QueryBuilder; + +/** + * Excludes every FetchDataFalseStateOptionsEntity row from item queries. A fetch_data=false request must + * return a getReference() and skip the query entirely, so this extension must never run for it; if it does, + * the item resolves to null. Scoped to the fixture entity so it does not affect other resources. + */ +final class FetchDataFalseStateOptionsItemExtension implements QueryItemExtensionInterface +{ + public function applyToItem(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, array $identifiers, ?Operation $operation = null, array $context = []): void + { + if (FetchDataFalseStateOptionsEntity::class !== $resourceClass) { + return; + } + + $queryBuilder->andWhere('1 = 0'); + } +} diff --git a/tests/Fixtures/TestBundle/Entity/FetchDataFalseStateOptionsEntity.php b/tests/Fixtures/TestBundle/Entity/FetchDataFalseStateOptionsEntity.php new file mode 100644 index 00000000000..80f805c13e5 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/FetchDataFalseStateOptionsEntity.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; + +use Doctrine\ORM\Mapping as ORM; + +/** + * Backing entity for the FetchDataFalseStateOptions DTO resource. + */ +#[ORM\Entity] +class FetchDataFalseStateOptionsEntity +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + public ?int $id = null; + + #[ORM\Column(length: 255)] + public ?string $name = null; + + public function getId(): ?int + { + return $this->id; + } +} diff --git a/tests/Fixtures/app/config/config_common.yml b/tests/Fixtures/app/config/config_common.yml index d03cbd48b35..48da39cf1b4 100644 --- a/tests/Fixtures/app/config/config_common.yml +++ b/tests/Fixtures/app/config/config_common.yml @@ -131,6 +131,10 @@ services: tags: - name: 'api_platform.state_provider' + ApiPlatform\Tests\Fixtures\TestBundle\Doctrine\Orm\FetchDataFalseStateOptionsItemExtension: + tags: + - name: 'api_platform.doctrine.orm.query_extension.item' + ApiPlatform\Tests\Fixtures\TestBundle\State\DummyCollectionDtoProvider: class: 'ApiPlatform\Tests\Fixtures\TestBundle\State\DummyCollectionDtoProvider' public: false diff --git a/tests/Functional/Doctrine/FetchDataFalseStateOptionsResourceTest.php b/tests/Functional/Doctrine/FetchDataFalseStateOptionsResourceTest.php new file mode 100644 index 00000000000..f493fd8a561 --- /dev/null +++ b/tests/Functional/Doctrine/FetchDataFalseStateOptionsResourceTest.php @@ -0,0 +1,73 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional\Doctrine; + +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\State\ProviderInterface; +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\FetchDataFalseStateOptions; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FetchDataFalseStateOptionsEntity; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class FetchDataFalseStateOptionsResourceTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [FetchDataFalseStateOptions::class]; + } + + public function testFetchDataFalseReturnsReferenceForStateOptionsResourceAndSkipsItemExtensions(): void + { + $container = static::getContainer(); + if ('mongodb' === $container->getParameter('kernel.environment')) { + $this->markTestSkipped('getReference() is ORM specific.'); + } + + $this->recreateSchema([FetchDataFalseStateOptionsEntity::class]); + + $manager = $this->getManager(); + $entity = new FetchDataFalseStateOptionsEntity(); + $entity->name = 'test'; + $manager->persist($entity); + $manager->flush(); + $id = $entity->id; + $manager->clear(); + + /** @var ResourceMetadataCollectionFactoryInterface $factory */ + $factory = $container->get('api_platform.metadata.resource.metadata_collection_factory'); + $operation = $factory->create(FetchDataFalseStateOptions::class)->getOperation(); + + /** @var ProviderInterface $provider */ + $provider = $container->get('api_platform.doctrine.orm.state.item_provider'); + + // FetchDataFalseStateOptionsItemExtension excludes every row from the item query. With fetch_data=false + // the provider must return EntityManager::getReference() and never build that query, so the extension is + // skipped and the reference is returned. Before the DTO fix the resource-vs-entity class mismatch made + // getReferenceIdentifiers() return null, the provider fell through to the query, the extension ran, and + // this resolved to null. + $reference = $provider->provide($operation, ['id' => $id], ['fetch_data' => false]); + + $this->assertInstanceOf(FetchDataFalseStateOptionsEntity::class, $reference); + $this->assertSame($id, $reference->getId()); + } +} From 079c7461f947714f15bb29b41fb143ed4bb171ea Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Wed, 8 Jul 2026 08:54:33 +0200 Subject: [PATCH 14/25] fix(openapi): don't inherit name converter from a possibly-missing Symfony parent (#8386) Fixes #8385 --- src/Symfony/Bundle/Resources/config/openapi.php | 7 +++++-- .../ApiPlatformExtensionTest.php | 15 +++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/Symfony/Bundle/Resources/config/openapi.php b/src/Symfony/Bundle/Resources/config/openapi.php index a45f051f9fc..c0cdfd3286b 100644 --- a/src/Symfony/Bundle/Resources/config/openapi.php +++ b/src/Symfony/Bundle/Resources/config/openapi.php @@ -23,14 +23,17 @@ use ApiPlatform\OpenApi\Serializer\SerializerContextBuilder; use ApiPlatform\OpenApi\State\OpenApiProvider; use ApiPlatform\Serializer\JsonEncoder; +use Symfony\Component\Serializer\NameConverter\MetadataAwareNameConverter; use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; use Symfony\Component\Serializer\Serializer; return function (ContainerConfigurator $container) { $services = $container->services(); - $services->set('api_platform.openapi.name_converter') - ->parent('serializer.name_converter.metadata_aware.abstract'); + // A metadata-aware name converter isolated from Symfony's global one so the generated + // document only honors serializer metadata (e.g. #[SerializedName]) without the global name converter. + $services->set('api_platform.openapi.name_converter', MetadataAwareNameConverter::class) + ->args([service('serializer.mapping.class_metadata_factory')]); $services->set('api_platform.openapi.normalizer', OpenApiNormalizer::class) ->args([inline_service(Serializer::class)->arg(0, [inline_service(ObjectNormalizer::class)->arg(0, service('serializer.mapping.class_metadata_factory'))->arg(1, service('api_platform.openapi.name_converter'))->arg(2, service('api_platform.property_accessor'))->arg(3, service('api_platform.property_info'))])->arg(1, [service('serializer.encoder.json')])]) diff --git a/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index 5505f07337a..854a06f2515 100644 --- a/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -32,9 +32,11 @@ use PHPUnit\Framework\TestCase; use Symfony\Bundle\SecurityBundle\SecurityBundle; use Symfony\Bundle\TwigBundle\TwigBundle; +use Symfony\Component\DependencyInjection\ChildDefinition; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\ParameterBag\ParameterBag; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Serializer\NameConverter\MetadataAwareNameConverter; class ApiPlatformExtensionTest extends TestCase { @@ -301,6 +303,19 @@ public function testSwaggerUiEnabledConfiguration(): void $this->assertContainerHas($services); } + public function testOpenApiNameConverterDoesNotDependOnMissingSymfonyParent(): void + { + $config = self::DEFAULT_CONFIG; + $config['api_platform']['enable_swagger'] = true; + + (new ApiPlatformExtension())->load($config, $this->container); + + $definition = $this->container->getDefinition('api_platform.openapi.name_converter'); + + $this->assertNotInstanceOf(ChildDefinition::class, $definition, 'The OpenAPI name converter must not depend on Symfony\'s "serializer.name_converter.metadata_aware.abstract" parent, which does not exist when no name converter is configured.'); + $this->assertSame(MetadataAwareNameConverter::class, $definition->getClass()); + } + public function testReDocEnabledWithSwaggerUiDisabledConfiguration(): void { $config = self::DEFAULT_CONFIG; From b396ff938272280a26d38d89011970dcee56d721 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Sat, 11 Jul 2026 09:03:31 +0200 Subject: [PATCH 15/25] fix(serializer): report enum backing type in denormalization violations (#8389) Closes #8388 --- src/Serializer/AbstractItemNormalizer.php | 10 +++++-- src/State/Provider/DeserializeProvider.php | 11 ++++++-- .../EnumDenormalizationValidationTest.php | 28 +++++++++++++++++++ 3 files changed, 43 insertions(+), 6 deletions(-) diff --git a/src/Serializer/AbstractItemNormalizer.php b/src/Serializer/AbstractItemNormalizer.php index 43a8ac5e079..253f1a0b305 100644 --- a/src/Serializer/AbstractItemNormalizer.php +++ b/src/Serializer/AbstractItemNormalizer.php @@ -1445,12 +1445,16 @@ private function createAndValidateAttributeValue(string $attribute, mixed $value if ($denormalizationException) { if ($type instanceof Type && $type->isSatisfiedBy(static fn ($type) => $type instanceof BuiltinType) && !$type->isSatisfiedBy($typeIsResourceClass)) { - // If the exception came from object denormalization, preserve its message as it's more specific - $message = $type->isSatisfiedBy(static fn ($type) => $type instanceof ObjectType) + // If the exception came from object denormalization (e.g. BackedEnumNormalizer for a + // nullable backed enum), preserve its more specific message and its user-facing hint flag; + // the expected types still carry the object/enum class so the failure stays recognizable + // downstream (see DeserializeProvider). + $isObject = $type->isSatisfiedBy(static fn ($type) => $type instanceof ObjectType); + $message = $isObject ? $denormalizationException->getMessage() : \sprintf('The type of the "%s" attribute must be "%s", "%s" given.', $attribute, $type, \gettype($value)); - throw NotNormalizableValueException::createForUnexpectedDataType($message, $value, array_map(strval(...), $types), $context['deserialization_path'] ?? null, false, 0, $denormalizationException); + throw NotNormalizableValueException::createForUnexpectedDataType($message, $value, array_map(strval(...), $types), $context['deserialization_path'] ?? null, $isObject && $denormalizationException->canUseMessageForUser(), 0, $denormalizationException); } throw $denormalizationException; diff --git a/src/State/Provider/DeserializeProvider.php b/src/State/Provider/DeserializeProvider.php index 338c8371418..e264cc19e0c 100644 --- a/src/State/Provider/DeserializeProvider.php +++ b/src/State/Provider/DeserializeProvider.php @@ -143,14 +143,19 @@ private function normalizeExpectedTypes(?array $expectedTypes = null): array $normalizedType = $expectedType; if (class_exists($expectedType) || interface_exists($expectedType)) { - $classReflection = new \ReflectionClass($expectedType); - $normalizedType = $classReflection->getShortName(); + // A backed enum is sent over the wire as its backing scalar (e.g. "string"), not as the + // PHP enum class, so report the JSON-visible type rather than the internal FQCN (#8388). + if (is_subclass_of($expectedType, \BackedEnum::class) && ($backingType = (new \ReflectionEnum($expectedType))->getBackingType())) { + $normalizedType = (string) $backingType; + } else { + $normalizedType = (new \ReflectionClass($expectedType))->getShortName(); + } } $normalizedTypes[] = $normalizedType; } - return $normalizedTypes; + return array_values(array_unique($normalizedTypes)); } private function createViolationFromException(NotNormalizableValueException $exception): ConstraintViolation diff --git a/tests/Functional/EnumDenormalizationValidationTest.php b/tests/Functional/EnumDenormalizationValidationTest.php index 3fa939623c8..5b6e8cd587e 100644 --- a/tests/Functional/EnumDenormalizationValidationTest.php +++ b/tests/Functional/EnumDenormalizationValidationTest.php @@ -84,6 +84,34 @@ public function testInvalidBackedEnumValueWithCollectDenormalizationErrors(): vo $this->assertNotNull($this->findViolation($content['violations'] ?? [], 'gender')); } + /** + * @see https://github.com/api-platform/core/issues/8388 + */ + #[IgnoreDeprecations] + public function testWrongTypeForBackedEnumReportsAcceptedScalarTypes(): void + { + if (InstalledVersions::satisfies(new VersionParser(), 'symfony/serializer', '>=8.1')) { + $this->expectUserDeprecationMessage('Since symfony/serializer 8.1: The "Symfony\Component\Serializer\Exception\PartialDenormalizationException::getErrors()" method is deprecated, use "Symfony\Component\Serializer\Exception\PartialDenormalizationException::getNotNormalizableValueErrors()" instead.'); + } + + $response = static::createClient()->request('POST', '/enum_validation_resources_collect', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['gender' => true], + ]); + + $this->assertResponseStatusCodeSame(422); + + $content = $response->toArray(false); + $genderViolation = $this->findViolation($content['violations'] ?? [], 'gender'); + $this->assertNotNull($genderViolation); + // The message must name what a JSON consumer can actually send: the enum's backing scalar + // ("string") and, since the property is nullable, "null" — never the internal PHP type + // "GenderTypeEnum|null", which appears nowhere in the OpenAPI schema. + $this->assertSame('This value should be of type string|null.', $genderViolation['message']); + $this->assertStringNotContainsString('GenderTypeEnum', $genderViolation['message']); + $this->assertStringContainsString('enumeration case of type', $genderViolation['hint'] ?? ''); + } + private function findViolation(array $violations, string $propertyPath): ?array { foreach ($violations as $violation) { From 3d48a4aa4d87f1fa80fe19361393d645d0d013c2 Mon Sep 17 00:00:00 2001 From: Ben Davies Date: Sun, 12 Jul 2026 00:00:58 +0100 Subject: [PATCH 16/25] fix(serializer): preserve denormalization errors for nullable object properties (#8393) --- src/Serializer/AbstractItemNormalizer.php | 27 +++++++ .../Tests/AbstractItemNormalizerTest.php | 78 +++++++++++++++++++ tests/Functional/ValidationTest.php | 4 +- 3 files changed, 108 insertions(+), 1 deletion(-) diff --git a/src/Serializer/AbstractItemNormalizer.php b/src/Serializer/AbstractItemNormalizer.php index 253f1a0b305..365bdbc4319 100644 --- a/src/Serializer/AbstractItemNormalizer.php +++ b/src/Serializer/AbstractItemNormalizer.php @@ -51,6 +51,7 @@ use Symfony\Component\TypeInfo\Type\BuiltinType; use Symfony\Component\TypeInfo\Type\CollectionType; use Symfony\Component\TypeInfo\Type\CompositeTypeInterface; +use Symfony\Component\TypeInfo\Type\EnumType; use Symfony\Component\TypeInfo\Type\NullableType; use Symfony\Component\TypeInfo\Type\ObjectType; use Symfony\Component\TypeInfo\Type\WrappingTypeInterface; @@ -1445,6 +1446,32 @@ private function createAndValidateAttributeValue(string $attribute, mixed $value if ($denormalizationException) { if ($type instanceof Type && $type->isSatisfiedBy(static fn ($type) => $type instanceof BuiltinType) && !$type->isSatisfiedBy($typeIsResourceClass)) { + // When the union contains an object member that is not a resource class (e.g. a nullable + // Uuid), the exception stashed while denormalizing it comes from its dedicated normalizer + // and carries the expected types actually accepted on the wire (e.g. ["string"]) plus a + // hint that is safe to expose to the user. Rebuilding the error from the PHP type union + // would report unactionable expected types such as "Uuid|null" instead, so re-throw it, + // only extending its expected types with "null" when the property also accepts null. + // Enum members are deliberately not re-thrown here: their violations are rendered from + // the rebuilt exception below, whose expected types are mapped to the enum backing type + // downstream (see DeserializeProvider). + foreach ($types as $memberType) { + while ($memberType instanceof WrappingTypeInterface && !$memberType instanceof CollectionType) { + $memberType = $memberType->getWrappedType(); + } + + if (!$memberType instanceof ObjectType || $memberType instanceof EnumType || $this->resourceClassResolver->isResourceClass($memberType->getClassName())) { + continue; + } + + $expectedTypes = $denormalizationException->getExpectedTypes(); + if ($type->isNullable() && $expectedTypes && !\in_array('null', $expectedTypes, true)) { + throw NotNormalizableValueException::createForUnexpectedDataType($denormalizationException->getMessage(), $value, [...$expectedTypes, 'null'], $denormalizationException->getPath() ?? $context['deserialization_path'] ?? null, $denormalizationException->canUseMessageForUser(), $denormalizationException->getCode(), $denormalizationException); + } + + throw $denormalizationException; + } + // If the exception came from object denormalization (e.g. BackedEnumNormalizer for a // nullable backed enum), preserve its more specific message and its user-facing hint flag; // the expected types still carry the object/enum class so the failure stays recognizable diff --git a/src/Serializer/Tests/AbstractItemNormalizerTest.php b/src/Serializer/Tests/AbstractItemNormalizerTest.php index e09e9f05a48..bc17aae5005 100644 --- a/src/Serializer/Tests/AbstractItemNormalizerTest.php +++ b/src/Serializer/Tests/AbstractItemNormalizerTest.php @@ -1387,6 +1387,84 @@ public function testDenormalizeNullableCollectionOfBackedEnums(): void $this->assertInstanceOf(Dummy::class, $actual); } + public function testDenormalizeWrongTypedValueForNullableObjectPropertyPreservesNormalizerException(): void + { + // Nullable object types (NullableType wrapping ObjectType) only exist in the native TypeInfo system. + if (!method_exists(PropertyInfoExtractor::class, 'getType')) { + $this->markTestSkipped('Requires symfony/property-info >= 7.1 (native types).'); + } + + // What Symfony's DateTimeNormalizer throws for a value it cannot parse. + $normalizerException = NotNormalizableValueException::createForUnexpectedDataType('The data is either not an string, an empty string, or null; you should pass a string that can be parsed with the passed format or a valid DateTime string.', false, ['string'], 'dummyDate', true); + + $normalizer = $this->createNormalizerForObjectProperty('dummyDate', Type::nullable(Type::object(\DateTimeImmutable::class)), \DateTimeImmutable::class, $normalizerException); + + try { + $normalizer->denormalize(['dummyDate' => false], Dummy::class); + $this->fail('Expected a NotNormalizableValueException to be thrown.'); + } catch (NotNormalizableValueException $exception) { + $this->assertSame(['string', 'null'], $exception->getExpectedTypes()); + $this->assertTrue($exception->canUseMessageForUser()); + $this->assertSame($normalizerException->getMessage(), $exception->getMessage()); + $this->assertSame('dummyDate', $exception->getPath()); + } + } + + public function testDenormalizeWrongTypedValueForNonNullableObjectPropertyPreservesNormalizerException(): void + { + if (!method_exists(PropertyInfoExtractor::class, 'getType')) { + $this->markTestSkipped('Requires symfony/property-info >= 7.1 (native types).'); + } + + $normalizerException = NotNormalizableValueException::createForUnexpectedDataType('The data is either not an string, an empty string, or null; you should pass a string that can be parsed with the passed format or a valid DateTime string.', false, ['string'], 'dummyDate', true); + + $normalizer = $this->createNormalizerForObjectProperty('dummyDate', Type::object(\DateTimeImmutable::class), \DateTimeImmutable::class, $normalizerException); + + try { + $normalizer->denormalize(['dummyDate' => false], Dummy::class); + $this->fail('Expected a NotNormalizableValueException to be thrown.'); + } catch (NotNormalizableValueException $exception) { + $this->assertSame(['string'], $exception->getExpectedTypes()); + $this->assertTrue($exception->canUseMessageForUser()); + $this->assertSame($normalizerException->getMessage(), $exception->getMessage()); + $this->assertSame('dummyDate', $exception->getPath()); + } + } + + private function createNormalizerForObjectProperty(string $attribute, Type $nativeType, string $className, NotNormalizableValueException $normalizerException): AbstractItemNormalizer + { + $propertyNameCollectionFactory = $this->createStub(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactory->method('create')->willReturn(new PropertyNameCollection([$attribute])); + + $propertyMetadataFactory = $this->createStub(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactory->method('create')->willReturn( + (new ApiProperty()) + ->withNativeType($nativeType) + ->withWritable(true) + ); + + $resourceClassResolver = $this->createStub(ResourceClassResolverInterface::class); + $resourceClassResolver->method('isResourceClass')->willReturnMap([ + [Dummy::class, true], + [$className, false], + ]); + $resourceClassResolver->method('getResourceClass')->willReturnMap([ + [null, Dummy::class, Dummy::class], + ]); + + $propertyAccessor = $this->createStub(PropertyAccessorInterface::class); + $iriConverter = $this->createStub(IriConverterInterface::class); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(DenormalizerInterface::class); + $serializerProphecy->denormalize(false, $className, null, Argument::type('array'))->willThrow($normalizerException); + + $normalizer = new class($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, null, null, [], null, null) extends AbstractItemNormalizer {}; + $normalizer->setSerializer($serializerProphecy->reveal()); + + return $normalizer; + } + public function testTypeConfusionGuardPreservesPathAndExpectedType(): void { $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); diff --git a/tests/Functional/ValidationTest.php b/tests/Functional/ValidationTest.php index c82238d75d0..7c9f4c214a0 100644 --- a/tests/Functional/ValidationTest.php +++ b/tests/Functional/ValidationTest.php @@ -119,8 +119,10 @@ public function testPostWithDenormalizationErrorsCollected(): void if (!method_exists(PropertyInfoExtractor::class, 'getType')) { $this->assertSame('This value should be of type uuid.', $violationUuid['message']); } else { - $this->assertSame('This value should be of type UuidInterface|null.', $violationUuid['message']); + $this->assertSame('This value should be of type uuid|null.', $violationUuid['message']); } + $this->assertArrayHasKey('hint', $violationUuid); + $this->assertSame('Invalid UUID string: y', $violationUuid['hint']); $violationRelatedDummy = $findViolation('relatedDummy'); $this->assertNotNull($violationRelatedDummy); From 815f7f401e6e3acdf0530283cf9dedcddc537839 Mon Sep 17 00:00:00 2001 From: soyuka Date: Sun, 12 Jul 2026 08:11:13 +0200 Subject: [PATCH 17/25] doc: changelog 4.3.17 --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a392a1a116..54561eba481 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## v4.3.17 + +### Bug fixes + +* [079c7461f](https://github.com/api-platform/core/commit/079c7461f947714f15bb29b41fb143ed4bb171ea) fix(openapi): don't inherit name converter from a possibly-missing Symfony parent (#8386) +* [3d48a4aa4](https://github.com/api-platform/core/commit/3d48a4aa4d87f1fa80fe19361393d645d0d013c2) fix(serializer): preserve denormalization errors for nullable object properties (#8393) +* [56de59737](https://github.com/api-platform/core/commit/56de59737c53e18120ed0b9b6c40633c109d58c2) fix(doctrine): fetch_data=false reference for stateOptions resources (#8387) +* [b396ff938](https://github.com/api-platform/core/commit/b396ff938272280a26d38d89011970dcee56d721) fix(serializer): report enum backing type in denormalization violations (#8389) + ## v4.3.16 ### Bug fixes From 400463f9f63d52c1e78f79bf7799e58b1da006f7 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Sun, 12 Jul 2026 08:24:00 +0200 Subject: [PATCH 18/25] chore(graphql): remove stray committed foo.diff patch file (#8395) --- src/GraphQl/foo.diff | 289 ------------------------------------------- 1 file changed, 289 deletions(-) delete mode 100644 src/GraphQl/foo.diff diff --git a/src/GraphQl/foo.diff b/src/GraphQl/foo.diff deleted file mode 100644 index 37938da2912..00000000000 --- a/src/GraphQl/foo.diff +++ /dev/null @@ -1,289 +0,0 @@ -diff --git a/src/Doctrine/Common/Filter/ParameterAwareFilterInterface.php b/src/Doctrine/Common/Filter/ParameterAwareFilterInterface.php -new file mode 100644 -index 00000000000..d1974a32c55 ---- /dev/null -+++ b/src/Doctrine/Common/Filter/ParameterAwareFilterInterface.php -@@ -0,0 +1,31 @@ -+ -+ * -+ * For the full copyright and license information, please view the LICENSE -+ * file that was distributed with this source code. -+ */ -+ -+declare(strict_types=1); -+ -+namespace ApiPlatform\Doctrine\Common\Filter; -+ -+use ApiPlatform\Metadata\Operation; -+ -+/** -+ * Interface for filters that can be applied by a ParameterExtension. -+ * -+ * @author Antoine Bluchet -+ */ -+interface ParameterAwareFilterInterface -+{ -+ /** -+ * Applies the filter to the query. -+ * -+ * @param array $context -+ */ -+ public function apply(object $queryBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void; -+} -diff --git a/src/Doctrine/Common/Filter/PropertyAwareFilterInterface.php b/src/Doctrine/Common/Filter/PropertyAwareFilterInterface.php -index aa0857cef20..1b041f480fb 100644 ---- a/src/Doctrine/Common/Filter/PropertyAwareFilterInterface.php -+++ b/src/Doctrine/Common/Filter/PropertyAwareFilterInterface.php -@@ -16,6 +16,8 @@ - /** - * @author Antoine Bluchet - * -+ * @method ?array getProperties() -+ * - * @experimental - */ - interface PropertyAwareFilterInterface -@@ -24,4 +26,9 @@ interface PropertyAwareFilterInterface - * @param string[] $properties - */ - public function setProperties(array $properties): void; -+ -+ // /** -+ // * @return string[] -+ // */ -+ // public function getProperties(): ?array; - } -diff --git a/src/Doctrine/Common/Filter/PropertyPlaceholderOpenApiParameterTrait.php b/src/Doctrine/Common/Filter/PropertyPlaceholderOpenApiParameterTrait.php -index f2f09e5758c..3acab18a05f 100644 ---- a/src/Doctrine/Common/Filter/PropertyPlaceholderOpenApiParameterTrait.php -+++ b/src/Doctrine/Common/Filter/PropertyPlaceholderOpenApiParameterTrait.php -@@ -23,16 +23,6 @@ trait PropertyPlaceholderOpenApiParameterTrait - */ - public function getOpenApiParameters(Parameter $parameter): ?array - { -- if (str_contains($parameter->getKey(), ':property')) { -- $parameters = []; -- $key = str_replace('[:property]', '', $parameter->getKey()); -- foreach (array_keys($parameter->getExtraProperties()['_properties'] ?? []) as $property) { -- $parameters[] = new OpenApiParameter(name: \sprintf('%s[%s]', $key, $property), in: 'query'); -- } -- -- return $parameters; -- } -- -- return null; -+ return [new OpenApiParameter(name: $parameter->getKey(), in: 'query')]; - } - } -diff --git a/src/Doctrine/Common/ParameterExtensionTrait.php b/src/Doctrine/Common/ParameterExtensionTrait.php -new file mode 100644 -index 00000000000..4d865a8730f ---- /dev/null -+++ b/src/Doctrine/Common/ParameterExtensionTrait.php -@@ -0,0 +1,63 @@ -+ -+ * -+ * For the full copyright and license information, please view the LICENSE -+ * file that was distributed with this source code. -+ */ -+ -+declare(strict_types=1); -+ -+namespace ApiPlatform\Doctrine\Common; -+ -+use ApiPlatform\Doctrine\Common\Filter\LoggerAwareInterface; -+use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface; -+use ApiPlatform\Doctrine\Common\Filter\PropertyAwareFilterInterface; -+use ApiPlatform\Metadata\Parameter; -+use Doctrine\Persistence\ManagerRegistry; -+use Psr\Container\ContainerInterface; -+use Psr\Log\LoggerInterface; -+ -+trait ParameterExtensionTrait -+{ -+ use ParameterValueExtractorTrait; -+ -+ protected ContainerInterface $filterLocator; -+ protected ?ManagerRegistry $managerRegistry = null; -+ protected ?LoggerInterface $logger = null; -+ -+ /** -+ * @param object $filter the filter instance to configure -+ * @param Parameter $parameter the operation parameter associated with the filter -+ */ -+ private function configureFilter(object $filter, Parameter $parameter): void -+ { -+ if ($this->managerRegistry && $filter instanceof ManagerRegistryAwareInterface && !$filter->hasManagerRegistry()) { -+ $filter->setManagerRegistry($this->managerRegistry); -+ } -+ -+ if ($this->logger && $filter instanceof LoggerAwareInterface && !$filter->hasLogger()) { -+ $filter->setLogger($this->logger); -+ } -+ -+ if ($filter instanceof PropertyAwareFilterInterface) { -+ $properties = []; -+ // Check if the filter has getProperties method (e.g., if it's an AbstractFilter) -+ if (method_exists($filter, 'getProperties')) { // @phpstan-ignore-line todo 5.x remove this check @see interface -+ $properties = $filter->getProperties() ?? []; -+ } -+ -+ $propertyKey = $parameter->getProperty() ?? $parameter->getKey(); -+ foreach ($parameter->getProperties() ?? [$propertyKey] as $property) { -+ if (!isset($properties[$property])) { -+ $properties[$property] = $parameter->getFilterContext(); -+ } -+ } -+ -+ $filter->setProperties($properties); -+ } -+ } -+} -diff --git a/src/Doctrine/Common/ParameterValueExtractorTrait.php b/src/Doctrine/Common/ParameterValueExtractorTrait.php -index b50dede8f76..62c18f6483c 100644 ---- a/src/Doctrine/Common/ParameterValueExtractorTrait.php -+++ b/src/Doctrine/Common/ParameterValueExtractorTrait.php -@@ -23,10 +23,7 @@ trait ParameterValueExtractorTrait - private function extractParameterValue(Parameter $parameter, mixed $value): array - { - $key = $parameter->getProperty() ?? $parameter->getKey(); -- if (!str_contains($key, ':property')) { -- return [$key => $value]; -- } - -- return [str_replace('[:property]', '', $key) => $value]; -+ return [$key => $value]; - } - } -diff --git a/src/Doctrine/Odm/Extension/ParameterExtension.php b/src/Doctrine/Odm/Extension/ParameterExtension.php -index d68e6e9ed3b..d841fb9240e 100644 ---- a/src/Doctrine/Odm/Extension/ParameterExtension.php -+++ b/src/Doctrine/Odm/Extension/ParameterExtension.php -@@ -13,11 +13,9 @@ - - namespace ApiPlatform\Doctrine\Odm\Extension; - --use ApiPlatform\Doctrine\Common\Filter\LoggerAwareInterface; --use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface; --use ApiPlatform\Doctrine\Common\ParameterValueExtractorTrait; --use ApiPlatform\Doctrine\Odm\Filter\AbstractFilter; --use ApiPlatform\Doctrine\Odm\Filter\FilterInterface; -+use ApiPlatform\Doctrine\Common\Filter\PropertyAwareFilterInterface; -+use ApiPlatform\Doctrine\Common\ParameterExtensionTrait; -+use ApiPlatform\Doctrine\Odm\Filter\FilterInterface; // Explicitly import PropertyAwareFilterInterface - use ApiPlatform\Metadata\Operation; - use ApiPlatform\State\ParameterNotFound; - use Doctrine\Bundle\MongoDBBundle\ManagerRegistry; -@@ -32,13 +30,16 @@ - */ - final class ParameterExtension implements AggregationCollectionExtensionInterface, AggregationItemExtensionInterface - { -- use ParameterValueExtractorTrait; -+ use ParameterExtensionTrait; - - public function __construct( -- private readonly ContainerInterface $filterLocator, -- private readonly ?ManagerRegistry $managerRegistry = null, -- private readonly ?LoggerInterface $logger = null, -+ ContainerInterface $filterLocator, -+ ?ManagerRegistry $managerRegistry = null, -+ ?LoggerInterface $logger = null, - ) { -+ $this->filterLocator = $filterLocator; -+ $this->managerRegistry = $managerRegistry; -+ $this->logger = $logger; - } - - /** -@@ -66,28 +67,7 @@ private function applyFilter(Builder $aggregationBuilder, ?string $resourceClass - continue; - } - -- if ($this->managerRegistry && $filter instanceof ManagerRegistryAwareInterface && !$filter->hasManagerRegistry()) { -- $filter->setManagerRegistry($this->managerRegistry); -- } -- -- if ($this->logger && $filter instanceof LoggerAwareInterface && !$filter->hasLogger()) { -- $filter->setLogger($this->logger); -- } -- -- if ($filter instanceof AbstractFilter && !$filter->getProperties()) { -- $propertyKey = $parameter->getProperty() ?? $parameter->getKey(); -- -- if (str_contains($propertyKey, ':property')) { -- $extraProperties = $parameter->getExtraProperties()['_properties'] ?? []; -- foreach (array_keys($extraProperties) as $property) { -- $properties[$property] = $parameter->getFilterContext(); -- } -- } else { -- $properties = [$propertyKey => $parameter->getFilterContext()]; -- } -- -- $filter->setProperties($properties ?? []); -- } -+ $this->configureFilter($filter, $parameter); - - $context['filters'] = $values; - $context['parameter'] = $parameter; -diff --git a/src/Doctrine/Orm/Extension/ParameterExtension.php b/src/Doctrine/Orm/Extension/ParameterExtension.php -index b7c8d8938b9..0264567a602 100644 ---- a/src/Doctrine/Orm/Extension/ParameterExtension.php -+++ b/src/Doctrine/Orm/Extension/ParameterExtension.php -@@ -13,11 +13,8 @@ - - namespace ApiPlatform\Doctrine\Orm\Extension; - --use ApiPlatform\Doctrine\Common\Filter\LoggerAwareInterface; --use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface; - use ApiPlatform\Doctrine\Common\Filter\PropertyAwareFilterInterface; --use ApiPlatform\Doctrine\Common\ParameterValueExtractorTrait; --use ApiPlatform\Doctrine\Orm\Filter\AbstractFilter; -+use ApiPlatform\Doctrine\Common\ParameterExtensionTrait; // Explicitly import PropertyAwareFilterInterface - use ApiPlatform\Doctrine\Orm\Filter\FilterInterface; - use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; - use ApiPlatform\Metadata\Operation; -@@ -34,13 +31,16 @@ - */ - final class ParameterExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface - { -- use ParameterValueExtractorTrait; -+ use ParameterExtensionTrait; - - public function __construct( -- private readonly ContainerInterface $filterLocator, -- private readonly ?ManagerRegistry $managerRegistry = null, -- private readonly ?LoggerInterface $logger = null, -+ ContainerInterface $filterLocator, -+ ?ManagerRegistry $managerRegistry = null, -+ ?LoggerInterface $logger = null, - ) { -+ $this->filterLocator = $filterLocator; -+ $this->managerRegistry = $managerRegistry; -+ $this->logger = $logger; - } - - /** -@@ -68,30 +68,7 @@ private function applyFilter(QueryBuilder $queryBuilder, QueryNameGeneratorInter - continue; - } - -- if ($this->managerRegistry && $filter instanceof ManagerRegistryAwareInterface && !$filter->hasManagerRegistry()) { -- $filter->setManagerRegistry($this->managerRegistry); -- } -- -- if ($this->logger && $filter instanceof LoggerAwareInterface && !$filter->hasLogger()) { -- $filter->setLogger($this->logger); -- } -- -- if ($filter instanceof PropertyAwareFilterInterface) { -- $properties = []; From 722ed09e4f1cdadb8dbe27661e5fb3cdfd4bf262 Mon Sep 17 00:00:00 2001 From: soyuka Date: Sun, 12 Jul 2026 08:42:34 +0200 Subject: [PATCH 19/25] fix(validator): forward-port enum backing type into DenormalizationViolationFactory #8389 patched normalizeExpectedTypes() in DeserializeProvider on 4.3; 4.4 moved that logic to DenormalizationViolationFactory. Port the backed-enum backing-type reporting and result dedup so the fix survives the up-merge. --- src/Validator/DenormalizationViolationFactory.php | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/Validator/DenormalizationViolationFactory.php b/src/Validator/DenormalizationViolationFactory.php index 76b38e69cb2..2296c38ed9c 100644 --- a/src/Validator/DenormalizationViolationFactory.php +++ b/src/Validator/DenormalizationViolationFactory.php @@ -229,6 +229,13 @@ private function normalizeExpectedTypes(?array $expectedTypes): array $normalized = []; foreach ($expectedTypes ?? [] as $expectedType) { if (\is_string($expectedType) && (class_exists($expectedType) || interface_exists($expectedType))) { + // A backed enum is sent over the wire as its backing scalar (e.g. "string"), not as the + // PHP enum class, so report the JSON-visible type rather than the internal FQCN (#8388). + if (is_subclass_of($expectedType, \BackedEnum::class) && ($backingType = (new \ReflectionEnum($expectedType))->getBackingType())) { + $normalized[] = (string) $backingType; + continue; + } + $pos = strrpos($expectedType, '\\'); $normalized[] = false === $pos ? $expectedType : substr($expectedType, $pos + 1); continue; @@ -236,6 +243,6 @@ private function normalizeExpectedTypes(?array $expectedTypes): array $normalized[] = $expectedType; } - return $normalized; + return array_values(array_unique($normalized)); } } From 1cacc0ba3c65f1bb127a7564ae1eb935a9e86a90 Mon Sep 17 00:00:00 2001 From: soyuka Date: Sun, 12 Jul 2026 08:46:51 +0200 Subject: [PATCH 20/25] fix(test): drop stale serializer 8.1 deprecation guard on ported enum test The 3-way merge of #8389 correctly dropped the deprecation-expectation guard (imports + #[IgnoreDeprecations] + version check) that #8287 had already removed from the sibling test, since 4.4's DenormalizationViolationFactory prefers getNotNormalizableValueErrors() over the deprecated getErrors() whenever it exists and so never triggers that deprecation on symfony/serializer >=8.1. But #8389 also introduced a brand-new test reusing the same now-unimported VersionParser/IgnoreDeprecations symbols, which the textual merge could not reconcile: it left the merged file referencing classes with no matching use statement. Remove the same stale guard from the new test for the same reason #8287 removed it from the old one. --- tests/Functional/EnumDenormalizationValidationTest.php | 5 ----- 1 file changed, 5 deletions(-) diff --git a/tests/Functional/EnumDenormalizationValidationTest.php b/tests/Functional/EnumDenormalizationValidationTest.php index d75e4d3cf28..e469989fa2e 100644 --- a/tests/Functional/EnumDenormalizationValidationTest.php +++ b/tests/Functional/EnumDenormalizationValidationTest.php @@ -80,13 +80,8 @@ public function testInvalidBackedEnumValueWithCollectDenormalizationErrors(): vo /** * @see https://github.com/api-platform/core/issues/8388 */ - #[IgnoreDeprecations] public function testWrongTypeForBackedEnumReportsAcceptedScalarTypes(): void { - if (InstalledVersions::satisfies(new VersionParser(), 'symfony/serializer', '>=8.1')) { - $this->expectUserDeprecationMessage('Since symfony/serializer 8.1: The "Symfony\Component\Serializer\Exception\PartialDenormalizationException::getErrors()" method is deprecated, use "Symfony\Component\Serializer\Exception\PartialDenormalizationException::getNotNormalizableValueErrors()" instead.'); - } - $response = static::createClient()->request('POST', '/enum_validation_resources_collect', [ 'headers' => ['Content-Type' => 'application/ld+json'], 'json' => ['gender' => true], From db5c7d8b49bdc55cf9a751966100f9c2a3aa6220 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Sun, 12 Jul 2026 09:35:58 +0200 Subject: [PATCH 21/25] chore: remove @experimental from stabilized APIs (#8398) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the C.4 stabilization decision, @experimental is kept only on MCP classes. Elasticsearch, the State parameter providers (Security/IriConverter/ReadLink), PropertyAwareFilterInterface and the Laravel ParameterValidatorProvider are stable — drop the marker. Aligns 4.4 with main, which already stripped these. --- src/Doctrine/Common/Filter/PropertyAwareFilterInterface.php | 2 -- src/Elasticsearch/Exception/IndexNotFoundException.php | 2 -- src/Elasticsearch/Exception/NonUniqueIdentifierException.php | 2 -- src/Elasticsearch/Extension/AbstractFilterExtension.php | 2 -- src/Elasticsearch/Extension/ConstantScoreFilterExtension.php | 2 -- .../Extension/RequestBodySearchCollectionExtensionInterface.php | 2 -- src/Elasticsearch/Extension/SortExtension.php | 2 -- src/Elasticsearch/Extension/SortFilterExtension.php | 2 -- src/Elasticsearch/Filter/AbstractFilter.php | 2 -- src/Elasticsearch/Filter/AbstractSearchFilter.php | 2 -- src/Elasticsearch/Filter/ConstantScoreFilterInterface.php | 2 -- src/Elasticsearch/Filter/FilterInterface.php | 2 -- src/Elasticsearch/Filter/OrderFilter.php | 2 -- src/Elasticsearch/Filter/SortFilterInterface.php | 2 -- src/Elasticsearch/Filter/TermFilter.php | 2 -- src/Elasticsearch/Paginator.php | 2 -- src/Elasticsearch/Serializer/DocumentNormalizer.php | 2 -- src/Elasticsearch/Serializer/ItemNormalizer.php | 2 -- .../Serializer/NameConverter/InnerFieldsNameConverter.php | 2 -- src/Elasticsearch/Util/FieldDatatypeTrait.php | 2 -- src/Laravel/State/ParameterValidatorProvider.php | 2 -- src/State/ParameterProvider/IriConverterParameterProvider.php | 2 -- src/State/ParameterProvider/ReadLinkParameterProvider.php | 2 -- src/State/Provider/SecurityParameterProvider.php | 2 -- 24 files changed, 48 deletions(-) diff --git a/src/Doctrine/Common/Filter/PropertyAwareFilterInterface.php b/src/Doctrine/Common/Filter/PropertyAwareFilterInterface.php index 33b9fad001a..80b7eabcb6e 100644 --- a/src/Doctrine/Common/Filter/PropertyAwareFilterInterface.php +++ b/src/Doctrine/Common/Filter/PropertyAwareFilterInterface.php @@ -19,8 +19,6 @@ * @author Antoine Bluchet * * @method array|null getProperties() - * - * @experimental */ interface PropertyAwareFilterInterface { diff --git a/src/Elasticsearch/Exception/IndexNotFoundException.php b/src/Elasticsearch/Exception/IndexNotFoundException.php index a92528a0deb..24c4ed9e98d 100644 --- a/src/Elasticsearch/Exception/IndexNotFoundException.php +++ b/src/Elasticsearch/Exception/IndexNotFoundException.php @@ -16,8 +16,6 @@ /** * Index not found exception. * - * @experimental - * * @author Baptiste Meyer */ final class IndexNotFoundException extends \Exception implements ExceptionInterface diff --git a/src/Elasticsearch/Exception/NonUniqueIdentifierException.php b/src/Elasticsearch/Exception/NonUniqueIdentifierException.php index 624ff936c01..9d8d7710e9f 100644 --- a/src/Elasticsearch/Exception/NonUniqueIdentifierException.php +++ b/src/Elasticsearch/Exception/NonUniqueIdentifierException.php @@ -16,8 +16,6 @@ /** * Non unique identifier exception. * - * @experimental - * * @author Baptiste Meyer */ final class NonUniqueIdentifierException extends \Exception implements ExceptionInterface diff --git a/src/Elasticsearch/Extension/AbstractFilterExtension.php b/src/Elasticsearch/Extension/AbstractFilterExtension.php index 13e82800882..ac9ec377685 100644 --- a/src/Elasticsearch/Extension/AbstractFilterExtension.php +++ b/src/Elasticsearch/Extension/AbstractFilterExtension.php @@ -19,8 +19,6 @@ /** * Abstract class for easing the implementation of a filter extension. * - * @experimental - * * @author Baptiste Meyer */ abstract class AbstractFilterExtension implements RequestBodySearchCollectionExtensionInterface diff --git a/src/Elasticsearch/Extension/ConstantScoreFilterExtension.php b/src/Elasticsearch/Extension/ConstantScoreFilterExtension.php index d04eeb156ab..1736ec0e3b2 100644 --- a/src/Elasticsearch/Extension/ConstantScoreFilterExtension.php +++ b/src/Elasticsearch/Extension/ConstantScoreFilterExtension.php @@ -20,8 +20,6 @@ * * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-constant-score-query.html * - * @experimental - * * @author Baptiste Meyer */ final class ConstantScoreFilterExtension extends AbstractFilterExtension diff --git a/src/Elasticsearch/Extension/RequestBodySearchCollectionExtensionInterface.php b/src/Elasticsearch/Extension/RequestBodySearchCollectionExtensionInterface.php index 5556a16ca98..0752938e9d7 100644 --- a/src/Elasticsearch/Extension/RequestBodySearchCollectionExtensionInterface.php +++ b/src/Elasticsearch/Extension/RequestBodySearchCollectionExtensionInterface.php @@ -20,8 +20,6 @@ * * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-body.html * - * @experimental - * * @author Baptiste Meyer */ interface RequestBodySearchCollectionExtensionInterface diff --git a/src/Elasticsearch/Extension/SortExtension.php b/src/Elasticsearch/Extension/SortExtension.php index e327f7908f4..84da66a136e 100644 --- a/src/Elasticsearch/Extension/SortExtension.php +++ b/src/Elasticsearch/Extension/SortExtension.php @@ -25,8 +25,6 @@ * * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-sort.html * - * @experimental - * * @author Baptiste Meyer */ final class SortExtension implements RequestBodySearchCollectionExtensionInterface diff --git a/src/Elasticsearch/Extension/SortFilterExtension.php b/src/Elasticsearch/Extension/SortFilterExtension.php index 84aec9efe6c..d6ef1c1a46f 100644 --- a/src/Elasticsearch/Extension/SortFilterExtension.php +++ b/src/Elasticsearch/Extension/SortFilterExtension.php @@ -20,8 +20,6 @@ * * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-sort.html * - * @experimental - * * @author Baptiste Meyer */ final class SortFilterExtension extends AbstractFilterExtension diff --git a/src/Elasticsearch/Filter/AbstractFilter.php b/src/Elasticsearch/Filter/AbstractFilter.php index a305a57e03b..3083a42ebe1 100644 --- a/src/Elasticsearch/Filter/AbstractFilter.php +++ b/src/Elasticsearch/Filter/AbstractFilter.php @@ -31,8 +31,6 @@ /** * Abstract class with helpers for easing the implementation of a filter. * - * @experimental - * * @author Baptiste Meyer */ abstract class AbstractFilter implements FilterInterface diff --git a/src/Elasticsearch/Filter/AbstractSearchFilter.php b/src/Elasticsearch/Filter/AbstractSearchFilter.php index a20fe911f97..075ce9a55fd 100644 --- a/src/Elasticsearch/Filter/AbstractSearchFilter.php +++ b/src/Elasticsearch/Filter/AbstractSearchFilter.php @@ -30,8 +30,6 @@ /** * Abstract class with helpers for easing the implementation of a search filter like a term filter or a match filter. * - * @experimental - * * @internal * * @author Baptiste Meyer diff --git a/src/Elasticsearch/Filter/ConstantScoreFilterInterface.php b/src/Elasticsearch/Filter/ConstantScoreFilterInterface.php index 638be2d10a8..0c390414aa6 100644 --- a/src/Elasticsearch/Filter/ConstantScoreFilterInterface.php +++ b/src/Elasticsearch/Filter/ConstantScoreFilterInterface.php @@ -16,8 +16,6 @@ /** * Elasticsearch filter interface for a constant score query. * - * @experimental - * * @author Baptiste Meyer */ interface ConstantScoreFilterInterface extends FilterInterface diff --git a/src/Elasticsearch/Filter/FilterInterface.php b/src/Elasticsearch/Filter/FilterInterface.php index 13d4df2a0b1..bf2bdd35ee3 100644 --- a/src/Elasticsearch/Filter/FilterInterface.php +++ b/src/Elasticsearch/Filter/FilterInterface.php @@ -19,8 +19,6 @@ /** * Elasticsearch filter interface. * - * @experimental - * * @author Baptiste Meyer */ interface FilterInterface extends BaseFilterInterface diff --git a/src/Elasticsearch/Filter/OrderFilter.php b/src/Elasticsearch/Filter/OrderFilter.php index 481de5a1fd0..d0c1a7fc0ff 100644 --- a/src/Elasticsearch/Filter/OrderFilter.php +++ b/src/Elasticsearch/Filter/OrderFilter.php @@ -105,8 +105,6 @@ * * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-sort.html * - * @experimental - * * @author Baptiste Meyer */ final class OrderFilter extends AbstractFilter implements SortFilterInterface diff --git a/src/Elasticsearch/Filter/SortFilterInterface.php b/src/Elasticsearch/Filter/SortFilterInterface.php index 0434889c3ae..b94f6080683 100644 --- a/src/Elasticsearch/Filter/SortFilterInterface.php +++ b/src/Elasticsearch/Filter/SortFilterInterface.php @@ -16,8 +16,6 @@ /** * Elasticsearch filter interface for sorting. * - * @experimental - * * @author Baptiste Meyer */ interface SortFilterInterface extends FilterInterface diff --git a/src/Elasticsearch/Filter/TermFilter.php b/src/Elasticsearch/Filter/TermFilter.php index fba2c549c64..ff86bb0cf00 100644 --- a/src/Elasticsearch/Filter/TermFilter.php +++ b/src/Elasticsearch/Filter/TermFilter.php @@ -98,8 +98,6 @@ * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-term-query.html * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-terms-query.html * - * @experimental - * * @author Baptiste Meyer */ final class TermFilter extends AbstractSearchFilter diff --git a/src/Elasticsearch/Paginator.php b/src/Elasticsearch/Paginator.php index 2a1c6edc70e..9b6e7ee46e8 100644 --- a/src/Elasticsearch/Paginator.php +++ b/src/Elasticsearch/Paginator.php @@ -21,8 +21,6 @@ /** * Paginator for Elasticsearch. * - * @experimental - * * @author Baptiste Meyer */ final class Paginator implements \IteratorAggregate, PaginatorInterface diff --git a/src/Elasticsearch/Serializer/DocumentNormalizer.php b/src/Elasticsearch/Serializer/DocumentNormalizer.php index 189561f800b..6188b15606f 100644 --- a/src/Elasticsearch/Serializer/DocumentNormalizer.php +++ b/src/Elasticsearch/Serializer/DocumentNormalizer.php @@ -32,8 +32,6 @@ /** * Document denormalizer for Elasticsearch. * - * @experimental - * * @author Baptiste Meyer */ final class DocumentNormalizer implements NormalizerInterface, DenormalizerInterface, SerializerAwareInterface diff --git a/src/Elasticsearch/Serializer/ItemNormalizer.php b/src/Elasticsearch/Serializer/ItemNormalizer.php index e3cece34f23..10a53d5af28 100644 --- a/src/Elasticsearch/Serializer/ItemNormalizer.php +++ b/src/Elasticsearch/Serializer/ItemNormalizer.php @@ -22,8 +22,6 @@ /** * Item normalizer decorator that prevents {@see \ApiPlatform\Serializer\ItemNormalizer} * from taking over for the {@see DocumentNormalizer::FORMAT} format because of priorities. - * - * @experimental */ final class ItemNormalizer implements NormalizerInterface, DenormalizerInterface, SerializerAwareInterface { diff --git a/src/Elasticsearch/Serializer/NameConverter/InnerFieldsNameConverter.php b/src/Elasticsearch/Serializer/NameConverter/InnerFieldsNameConverter.php index dbf5b306e61..6ad041fa238 100644 --- a/src/Elasticsearch/Serializer/NameConverter/InnerFieldsNameConverter.php +++ b/src/Elasticsearch/Serializer/NameConverter/InnerFieldsNameConverter.php @@ -19,8 +19,6 @@ /** * Converts inner fields with a inner name converter. * - * @experimental - * * @author Baptiste Meyer */ final class InnerFieldsNameConverter implements NameConverterInterface diff --git a/src/Elasticsearch/Util/FieldDatatypeTrait.php b/src/Elasticsearch/Util/FieldDatatypeTrait.php index 25a0fe81bc8..6b5cf242ffb 100644 --- a/src/Elasticsearch/Util/FieldDatatypeTrait.php +++ b/src/Elasticsearch/Util/FieldDatatypeTrait.php @@ -27,8 +27,6 @@ * * @internal * - * @experimental - * * @author Baptiste Meyer */ trait FieldDatatypeTrait diff --git a/src/Laravel/State/ParameterValidatorProvider.php b/src/Laravel/State/ParameterValidatorProvider.php index 72276824602..89306e7bf59 100644 --- a/src/Laravel/State/ParameterValidatorProvider.php +++ b/src/Laravel/State/ParameterValidatorProvider.php @@ -25,8 +25,6 @@ * Validates parameters using the Laravel validator. * * @implements ProviderInterface - * - * @experimental */ final class ParameterValidatorProvider implements ProviderInterface { diff --git a/src/State/ParameterProvider/IriConverterParameterProvider.php b/src/State/ParameterProvider/IriConverterParameterProvider.php index 3d28f5be729..e8147041d0a 100644 --- a/src/State/ParameterProvider/IriConverterParameterProvider.php +++ b/src/State/ParameterProvider/IriConverterParameterProvider.php @@ -23,8 +23,6 @@ use Psr\Log\LoggerInterface; /** - * @experimental - * * @author Vincent Amstoutz */ final readonly class IriConverterParameterProvider implements ParameterProviderInterface diff --git a/src/State/ParameterProvider/ReadLinkParameterProvider.php b/src/State/ParameterProvider/ReadLinkParameterProvider.php index 906eb0ac9d1..4a43f6c3c20 100644 --- a/src/State/ParameterProvider/ReadLinkParameterProvider.php +++ b/src/State/ParameterProvider/ReadLinkParameterProvider.php @@ -26,8 +26,6 @@ /** * Checks if the linked resources have security attributes and prepares them for access checking. - * - * @experimental */ final class ReadLinkParameterProvider implements ParameterProviderInterface { diff --git a/src/State/Provider/SecurityParameterProvider.php b/src/State/Provider/SecurityParameterProvider.php index 301c2d5cb36..9b84c76d423 100644 --- a/src/State/Provider/SecurityParameterProvider.php +++ b/src/State/Provider/SecurityParameterProvider.php @@ -30,8 +30,6 @@ * Loops over parameters to check parameter security. * Throws an exception if security is not granted. * - * @experimental - * * @implements ProviderInterface */ final class SecurityParameterProvider implements ProviderInterface From 2d104d0ee8f26810720e186c67859511064a66d4 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Sun, 12 Jul 2026 09:36:01 +0200 Subject: [PATCH 22/25] chore: require Symfony ^7.4 across all components (#8397) Symfony 7.4 is the new LTS; bump every symfony/* floor to ^7.4 (|| ^8.0) across all components and drop 6.4 / 7.0-7.3 support. Also bump the extra.symfony.require declaration in each composer.json to ^7.4 || ^8.0 to stay consistent with the new floor (read by flex in CI). Cleanups the floor unblocks: - ParameterValidatorProvider: drop the getConstraint()/getCause() method_exists shims (guaranteed on ConstraintViolationInterface >= 7.2) - OperationRequestInitiatorTrait: drop a stale TODO --- composer.json | 70 +++++++++---------- src/Doctrine/Common/composer.json | 4 +- src/Doctrine/Odm/composer.json | 20 +++--- src/Doctrine/Orm/composer.json | 20 +++--- src/Documentation/composer.json | 2 +- src/Elasticsearch/composer.json | 16 ++--- src/GraphQl/composer.json | 10 +-- src/Hal/composer.json | 4 +- src/HttpCache/composer.json | 10 +-- src/Hydra/composer.json | 6 +- src/JsonApi/composer.json | 10 +-- src/JsonLd/composer.json | 4 +- src/JsonSchema/composer.json | 12 ++-- src/Laravel/composer.json | 4 +- src/Mcp/composer.json | 2 +- src/Metadata/composer.json | 18 ++--- src/OpenApi/composer.json | 14 ++-- src/RamseyUuid/composer.json | 6 +- src/Serializer/composer.json | 16 ++--- .../Util/OperationRequestInitiatorTrait.php | 3 - src/State/composer.json | 10 +-- .../State/ParameterValidatorProvider.php | 5 +- src/Symfony/composer.json | 28 ++++---- src/Validator/composer.json | 12 ++-- 24 files changed, 151 insertions(+), 155 deletions(-) diff --git a/composer.json b/composer.json index a91224ff2ce..c7c4272b392 100644 --- a/composer.json +++ b/composer.json @@ -53,7 +53,7 @@ "dev-main": "4.4.x-dev" }, "symfony": { - "require": "^6.4 || ^7.1 || ^8.0" + "require": "^7.4 || ^8.0" }, "pmu": { "projects": [ @@ -113,15 +113,15 @@ "psr/cache": "^1.0 || ^2.0 || ^3.0", "psr/container": "^1.0 || ^2.0", "symfony/deprecation-contracts": "^3.1", - "symfony/http-foundation": "^6.4.14 || ^7.0 || ^8.0", - "symfony/http-kernel": "^6.4.13 || ^7.0 || ^8.0", - "symfony/property-access": "^6.4 || ^7.0 || ^8.0", - "symfony/property-info": "^6.4 || ^7.1 || ^8.0", - "symfony/serializer": "^6.4.37 || ^7.4.9 || ^8.0.9", + "symfony/http-foundation": "^7.4 || ^8.0", + "symfony/http-kernel": "^7.4 || ^8.0", + "symfony/property-access": "^7.4 || ^8.0", + "symfony/property-info": "^7.4 || ^8.0", + "symfony/serializer": "^7.4.9 || ^8.0.9", "symfony/translation-contracts": "^3.3", "symfony/type-info": "^7.4 || ^8.0", - "symfony/validator": "^6.4.11 || ^7.1 || ^8.0", - "symfony/web-link": "^6.4 || ^7.1 || ^8.0", + "symfony/validator": "^7.4 || ^8.0", + "symfony/web-link": "^7.4 || ^8.0", "willdurand/negotiation": "^3.1" }, "require-dev": { @@ -157,39 +157,39 @@ "ramsey/uuid-doctrine": "^2.0", "soyuka/pmu": "^0.2.0", "soyuka/stubs-mongodb": "^1.0", - "symfony/asset": "^6.4 || ^7.0 || ^8.0", - "symfony/browser-kit": "^6.4 || ^7.0 || ^8.0", - "symfony/cache": "^6.4 || ^7.0 || ^8.0", - "symfony/config": "^6.4 || ^7.0 || ^8.0", - "symfony/console": "^6.4 || ^7.0 || ^8.0", - "symfony/css-selector": "^6.4 || ^7.0 || ^8.0", - "symfony/dependency-injection": "^6.4 || ^7.0 || ^8.0", - "symfony/doctrine-bridge": "^6.4.2 || ^7.1 || ^8.0", - "symfony/dom-crawler": "^6.4 || ^7.0 || ^8.0", - "symfony/error-handler": "^6.4 || ^7.0 || ^8.0", - "symfony/event-dispatcher": "^6.4 || ^7.0 || ^8.0", - "symfony/expression-language": "^6.4 || ^7.0 || ^8.0", - "symfony/finder": "^6.4 || ^7.0 || ^8.0", - "symfony/form": "^6.4 || ^7.0 || ^8.0", - "symfony/framework-bundle": "^6.4 || ^7.0 || ^8.0", - "symfony/http-client": "^6.4 || ^7.0 || ^8.0", - "symfony/intl": "^6.4 || ^7.0 || ^8.0", + "symfony/asset": "^7.4 || ^8.0", + "symfony/browser-kit": "^7.4 || ^8.0", + "symfony/cache": "^7.4 || ^8.0", + "symfony/config": "^7.4 || ^8.0", + "symfony/console": "^7.4 || ^8.0", + "symfony/css-selector": "^7.4 || ^8.0", + "symfony/dependency-injection": "^7.4 || ^8.0", + "symfony/doctrine-bridge": "^7.4 || ^8.0", + "symfony/dom-crawler": "^7.4 || ^8.0", + "symfony/error-handler": "^7.4 || ^8.0", + "symfony/event-dispatcher": "^7.4 || ^8.0", + "symfony/expression-language": "^7.4 || ^8.0", + "symfony/finder": "^7.4 || ^8.0", + "symfony/form": "^7.4 || ^8.0", + "symfony/framework-bundle": "^7.4 || ^8.0", + "symfony/http-client": "^7.4 || ^8.0", + "symfony/intl": "^7.4 || ^8.0", "symfony/json-streamer": "^7.4 || ^8.0", "symfony/maker-bundle": "^1.24", "symfony/mcp-bundle": "dev-main", "symfony/mercure-bundle": "*", - "symfony/messenger": "^6.4 || ^7.0 || ^8.0", + "symfony/messenger": "^7.4 || ^8.0", "symfony/object-mapper": "^7.4 || ^8.0", - "symfony/routing": "^6.4 || ^7.0 || ^8.0", - "symfony/security-bundle": "^6.4 || ^7.0 || ^8.0", - "symfony/security-core": "^6.4 || ^7.0 || ^8.0", - "symfony/stopwatch": "^6.4 || ^7.0 || ^8.0", - "symfony/string": "^6.4 || ^7.0 || ^8.0", - "symfony/twig-bundle": "^6.4 || ^7.0 || ^8.0", - "symfony/uid": "^6.4 || ^7.0 || ^8.0", + "symfony/routing": "^7.4 || ^8.0", + "symfony/security-bundle": "^7.4 || ^8.0", + "symfony/security-core": "^7.4 || ^8.0", + "symfony/stopwatch": "^7.4 || ^8.0", + "symfony/string": "^7.4 || ^8.0", + "symfony/twig-bundle": "^7.4 || ^8.0", + "symfony/uid": "^7.4 || ^8.0", "symfony/var-exporter": "^7.4 || ^8.0", - "symfony/web-profiler-bundle": "^6.4 || ^7.0 || ^8.0", - "symfony/yaml": "^6.4 || ^7.0 || ^8.0", + "symfony/web-profiler-bundle": "^7.4 || ^8.0", + "symfony/yaml": "^7.4 || ^8.0", "twig/twig": "^1.42.3 || ^2.12 || ^3.0", "webonyx/graphql-php": "^15.0" }, diff --git a/src/Doctrine/Common/composer.json b/src/Doctrine/Common/composer.json index b4f06cf68c7..cd6ab991a6c 100644 --- a/src/Doctrine/Common/composer.json +++ b/src/Doctrine/Common/composer.json @@ -35,7 +35,7 @@ "doctrine/orm": "^2.17 || ^3.0", "phpspec/prophecy-phpunit": "^2.2", "phpunit/phpunit": "^11.5 || ^12.2", - "symfony/type-info": "^7.3 || ^8.0" + "symfony/type-info": "^7.4 || ^8.0" }, "conflict": { "doctrine/persistence": "<1.3" @@ -67,7 +67,7 @@ "dev-4.1": "4.1.x-dev" }, "symfony": { - "require": "^6.4 || ^7.0 || ^8.0" + "require": "^7.4 || ^8.0" }, "thanks": { "name": "api-platform/api-platform", diff --git a/src/Doctrine/Odm/composer.json b/src/Doctrine/Odm/composer.json index e90a260c2ec..ff89d0b9fb0 100644 --- a/src/Doctrine/Odm/composer.json +++ b/src/Doctrine/Odm/composer.json @@ -30,21 +30,21 @@ "api-platform/serializer": "^4.4@alpha", "api-platform/state": "^4.4@alpha", "doctrine/mongodb-odm": "^2.10", - "symfony/property-info": "^6.4 || ^7.1 || ^8.0", - "symfony/type-info": "^7.3 || ^8.0" + "symfony/property-info": "^7.4 || ^8.0", + "symfony/type-info": "^7.4 || ^8.0" }, "require-dev": { "doctrine/doctrine-bundle": "^2.11 || ^3.1", "doctrine/mongodb-odm-bundle": "^5.0", "phpspec/prophecy-phpunit": "^2.2", "phpunit/phpunit": "^11.5 || ^12.2", - "symfony/cache": "^6.4 || ^7.0 || ^8.0", - "symfony/framework-bundle": "^6.4 || ^7.0 || ^8.0", - "symfony/property-access": "^6.4 || ^7.0 || ^8.0", - "symfony/serializer": "^6.4 || ^7.0 || ^8.0", - "symfony/uid": "^6.4 || ^7.0 || ^8.0", - "symfony/validator": "^6.4.11 || ^7.0 || ^8.0", - "symfony/yaml": "^6.4 || ^7.0 || ^8.0" + "symfony/cache": "^7.4 || ^8.0", + "symfony/framework-bundle": "^7.4 || ^8.0", + "symfony/property-access": "^7.4 || ^8.0", + "symfony/serializer": "^7.4 || ^8.0", + "symfony/uid": "^7.4 || ^8.0", + "symfony/validator": "^7.4 || ^8.0", + "symfony/yaml": "^7.4 || ^8.0" }, "autoload": { "psr-4": { @@ -68,7 +68,7 @@ "dev-4.1": "4.1.x-dev" }, "symfony": { - "require": "^6.4 || ^7.0 || ^8.0" + "require": "^7.4 || ^8.0" }, "thanks": { "name": "api-platform/api-platform", diff --git a/src/Doctrine/Orm/composer.json b/src/Doctrine/Orm/composer.json index 53ac6225bd2..c03c5e0f721 100644 --- a/src/Doctrine/Orm/composer.json +++ b/src/Doctrine/Orm/composer.json @@ -37,15 +37,15 @@ "phpunit/phpunit": "^11.5 || ^12.2", "ramsey/uuid": "^4.7", "ramsey/uuid-doctrine": "^2.0", - "symfony/cache": "^6.4 || ^7.0 || ^8.0", - "symfony/framework-bundle": "^6.4 || ^7.0 || ^8.0", - "symfony/property-access": "^6.4 || ^7.0 || ^8.0", - "symfony/serializer": "^6.4 || ^7.0 || ^8.0", - "symfony/property-info": "^6.4 || ^7.1 || ^8.0", - "symfony/uid": "^6.4 || ^7.0 || ^8.0", - "symfony/validator": "^6.4.11 || ^7.0 || ^8.0", - "symfony/yaml": "^6.4 || ^7.0 || ^8.0", - "symfony/type-info": "^7.3 || ^8.0" + "symfony/cache": "^7.4 || ^8.0", + "symfony/framework-bundle": "^7.4 || ^8.0", + "symfony/property-access": "^7.4 || ^8.0", + "symfony/serializer": "^7.4 || ^8.0", + "symfony/property-info": "^7.4 || ^8.0", + "symfony/uid": "^7.4 || ^8.0", + "symfony/validator": "^7.4 || ^8.0", + "symfony/yaml": "^7.4 || ^8.0", + "symfony/type-info": "^7.4 || ^8.0" }, "autoload": { "psr-4": { @@ -69,7 +69,7 @@ "dev-4.1": "4.1.x-dev" }, "symfony": { - "require": "^6.4 || ^7.0 || ^8.0" + "require": "^7.4 || ^8.0" }, "thanks": { "name": "api-platform/api-platform", diff --git a/src/Documentation/composer.json b/src/Documentation/composer.json index cb0346846a2..70cf509e52a 100644 --- a/src/Documentation/composer.json +++ b/src/Documentation/composer.json @@ -31,7 +31,7 @@ "dev-4.1": "4.1.x-dev" }, "symfony": { - "require": "^6.4 || ^7.0 || ^8.0" + "require": "^7.4 || ^8.0" }, "thanks": { "name": "api-platform/api-platform", diff --git a/src/Elasticsearch/composer.json b/src/Elasticsearch/composer.json index a587c37b5e0..d9a0e73e8f8 100644 --- a/src/Elasticsearch/composer.json +++ b/src/Elasticsearch/composer.json @@ -28,13 +28,13 @@ "api-platform/serializer": "^4.4@alpha", "api-platform/state": "^4.4@alpha", "elasticsearch/elasticsearch": "^7.17 || ^8.4 || ^9.0", - "symfony/cache": "^6.4 || ^7.0 || ^8.0", - "symfony/console": "^6.4 || ^7.0 || ^8.0", - "symfony/property-access": "^6.4 || ^7.0 || ^8.0", - "symfony/property-info": "^6.4 || ^7.1 || ^8.0", - "symfony/serializer": "^6.4 || ^7.0 || ^8.0", - "symfony/type-info": "^7.3 || ^8.0", - "symfony/uid": "^6.4 || ^7.0 || ^8.0" + "symfony/cache": "^7.4 || ^8.0", + "symfony/console": "^7.4 || ^8.0", + "symfony/property-access": "^7.4 || ^8.0", + "symfony/property-info": "^7.4 || ^8.0", + "symfony/serializer": "^7.4 || ^8.0", + "symfony/type-info": "^7.4 || ^8.0", + "symfony/uid": "^7.4 || ^8.0" }, "suggest": { "opensearch-project/opensearch-php": "Required to use OpenSearch instead of Elasticsearch (^2.5)" @@ -70,7 +70,7 @@ "dev-4.1": "4.1.x-dev" }, "symfony": { - "require": "^6.4 || ^7.0 || ^8.0" + "require": "^7.4 || ^8.0" }, "thanks": { "name": "api-platform/api-platform", diff --git a/src/GraphQl/composer.json b/src/GraphQl/composer.json index f355ce252f6..2bdba60c19d 100644 --- a/src/GraphQl/composer.json +++ b/src/GraphQl/composer.json @@ -24,9 +24,9 @@ "api-platform/metadata": "^4.4@alpha", "api-platform/state": "^4.4@alpha", "api-platform/serializer": "^4.4@alpha", - "symfony/property-info": "^7.1 || ^8.0", - "symfony/serializer": "^6.4 || ^7.1 || ^8.0", - "symfony/type-info": "^7.3 || ^8.0", + "symfony/property-info": "^7.4 || ^8.0", + "symfony/serializer": "^7.4 || ^8.0", + "symfony/type-info": "^7.4 || ^8.0", "webonyx/graphql-php": "^15.0", "willdurand/negotiation": "^3.1" }, @@ -35,7 +35,7 @@ "api-platform/validator": "^4.4@alpha", "twig/twig": "^1.42.3 || ^2.12 || ^3.0", "symfony/mercure-bundle": "*", - "symfony/routing": "^6.4 || ^7.0 || ^8.0", + "symfony/routing": "^7.4 || ^8.0", "phpunit/phpunit": "^11.5 || ^12.2" }, "autoload": { @@ -70,7 +70,7 @@ "dev-4.1": "4.1.x-dev" }, "symfony": { - "require": "^6.4 || ^7.0 || ^8.0" + "require": "^7.4 || ^8.0" }, "thanks": { "name": "api-platform/api-platform", diff --git a/src/Hal/composer.json b/src/Hal/composer.json index 1d06e9fb96b..8f8e8cc6345 100644 --- a/src/Hal/composer.json +++ b/src/Hal/composer.json @@ -26,7 +26,7 @@ "api-platform/metadata": "^4.4@alpha", "api-platform/documentation": "^4.4@alpha", "api-platform/serializer": "^4.4@alpha", - "symfony/type-info": "^7.3 || ^8.0" + "symfony/type-info": "^7.4 || ^8.0" }, "autoload": { "psr-4": { @@ -54,7 +54,7 @@ "dev-4.1": "4.1.x-dev" }, "symfony": { - "require": "^6.4 || ^7.0 || ^8.0" + "require": "^7.4 || ^8.0" }, "thanks": { "name": "api-platform/api-platform", diff --git a/src/HttpCache/composer.json b/src/HttpCache/composer.json index 210f560e526..34a97d153b2 100644 --- a/src/HttpCache/composer.json +++ b/src/HttpCache/composer.json @@ -25,14 +25,14 @@ "php": ">=8.2", "api-platform/metadata": "^4.4@alpha", "api-platform/state": "^4.4@alpha", - "symfony/http-foundation": "^6.4.14 || ^7.0 || ^8.0" + "symfony/http-foundation": "^7.4 || ^8.0" }, "require-dev": { "guzzlehttp/guzzle": "^6.0 || ^7.0 || ^8.0", - "symfony/dependency-injection": "^6.4 || ^7.0 || ^8.0", + "symfony/dependency-injection": "^7.4 || ^8.0", "phpspec/prophecy-phpunit": "^2.2", - "symfony/http-client": "^6.4 || ^7.0 || ^8.0", - "symfony/type-info": "^7.3 || ^8.0", + "symfony/http-client": "^7.4 || ^8.0", + "symfony/type-info": "^7.4 || ^8.0", "phpunit/phpunit": "^11.5 || ^12.2" }, "autoload": { @@ -61,7 +61,7 @@ "dev-4.1": "4.1.x-dev" }, "symfony": { - "require": "^6.4 || ^7.0 || ^8.0" + "require": "^7.4 || ^8.0" }, "thanks": { "name": "api-platform/api-platform", diff --git a/src/Hydra/composer.json b/src/Hydra/composer.json index 82ec7d60508..1d0bbb95df5 100644 --- a/src/Hydra/composer.json +++ b/src/Hydra/composer.json @@ -31,8 +31,8 @@ "api-platform/jsonld": "^4.4@alpha", "api-platform/json-schema": "^4.4@alpha", "api-platform/serializer": "^4.4@alpha", - "symfony/web-link": "^6.4 || ^7.1 || ^8.0", - "symfony/type-info": "^7.3 || ^8.0" + "symfony/web-link": "^7.4 || ^8.0", + "symfony/type-info": "^7.4 || ^8.0" }, "require-dev": { "api-platform/doctrine-odm": "^4.4@alpha", @@ -68,7 +68,7 @@ "dev-4.1": "4.1.x-dev" }, "symfony": { - "require": "^6.4 || ^7.0 || ^8.0" + "require": "^7.4 || ^8.0" }, "thanks": { "name": "api-platform/api-platform", diff --git a/src/JsonApi/composer.json b/src/JsonApi/composer.json index 4b619712e72..74f0aac257b 100644 --- a/src/JsonApi/composer.json +++ b/src/JsonApi/composer.json @@ -27,15 +27,15 @@ "api-platform/metadata": "^4.4@alpha", "api-platform/serializer": "^4.4@alpha", "api-platform/state": "^4.4@alpha", - "symfony/error-handler": "^6.4 || ^7.0 || ^8.0", - "symfony/http-foundation": "^6.4.14 || ^7.0 || ^8.0", - "symfony/type-info": "^7.3 || ^8.0" + "symfony/error-handler": "^7.4 || ^8.0", + "symfony/http-foundation": "^7.4 || ^8.0", + "symfony/type-info": "^7.4 || ^8.0" }, "require-dev": { "phpspec/prophecy": "^1.19", "phpspec/prophecy-phpunit": "^2.2", "phpunit/phpunit": "^11.5 || ^12.2", - "symfony/type-info": "^7.3 || ^8.0" + "symfony/type-info": "^7.4 || ^8.0" }, "autoload": { "psr-4": { @@ -63,7 +63,7 @@ "dev-4.1": "4.1.x-dev" }, "symfony": { - "require": "^6.4 || ^7.0 || ^8.0" + "require": "^7.4 || ^8.0" }, "thanks": { "name": "api-platform/api-platform", diff --git a/src/JsonLd/composer.json b/src/JsonLd/composer.json index ba8457bcf31..518537662b3 100644 --- a/src/JsonLd/composer.json +++ b/src/JsonLd/composer.json @@ -57,7 +57,7 @@ "dev-4.1": "4.1.x-dev" }, "symfony": { - "require": "^6.4 || ^7.0 || ^8.0" + "require": "^7.4 || ^8.0" }, "thanks": { "name": "api-platform/api-platform", @@ -68,7 +68,7 @@ "test": "./vendor/bin/phpunit" }, "require-dev": { - "symfony/type-info": "^7.3 || ^8.0", + "symfony/type-info": "^7.4 || ^8.0", "phpunit/phpunit": "^11.5 || ^12.2" }, "minimum-stability": "beta", diff --git a/src/JsonSchema/composer.json b/src/JsonSchema/composer.json index 14cd0d6a859..a9aedbe9899 100644 --- a/src/JsonSchema/composer.json +++ b/src/JsonSchema/composer.json @@ -26,11 +26,11 @@ "require": { "php": ">=8.2", "api-platform/metadata": "^4.4@alpha", - "symfony/console": "^6.4 || ^7.0 || ^8.0", - "symfony/property-info": "^6.4 || ^7.1 || ^8.0", - "symfony/serializer": "^6.4 || ^7.0 || ^8.0", - "symfony/type-info": "^7.3 || ^8.0", - "symfony/uid": "^6.4 || ^7.0 || ^8.0" + "symfony/console": "^7.4 || ^8.0", + "symfony/property-info": "^7.4 || ^8.0", + "symfony/serializer": "^7.4 || ^8.0", + "symfony/type-info": "^7.4 || ^8.0", + "symfony/uid": "^7.4 || ^8.0" }, "require-dev": { "phpspec/prophecy-phpunit": "^2.2", @@ -62,7 +62,7 @@ "dev-4.1": "4.1.x-dev" }, "symfony": { - "require": "^6.4 || ^7.0 || ^8.0" + "require": "^7.4 || ^8.0" }, "thanks": { "name": "api-platform/api-platform", diff --git a/src/Laravel/composer.json b/src/Laravel/composer.json index fc00e3684c4..2bb64434d95 100644 --- a/src/Laravel/composer.json +++ b/src/Laravel/composer.json @@ -49,7 +49,7 @@ "laravel/framework": "^11.0 || ^12.0 || ^13.0", "symfony/deprecation-contracts": "^3.6", "symfony/type-info": "^7.4 || ^8.0", - "symfony/web-link": "^6.4 || ^7.4 || ^8.0", + "symfony/web-link": "^7.4 || ^8.0", "willdurand/negotiation": "^3.1" }, "require-dev": { @@ -104,7 +104,7 @@ "dev-4.1": "4.1.x-dev" }, "symfony": { - "require": "^6.4 || ^7.4 || ^8.0" + "require": "^7.4 || ^8.0" }, "thanks": { "name": "api-platform/api-platform", diff --git a/src/Mcp/composer.json b/src/Mcp/composer.json index 9b8f8593105..971bc40f0e6 100644 --- a/src/Mcp/composer.json +++ b/src/Mcp/composer.json @@ -53,7 +53,7 @@ "dev-main": "4.4.x-dev" }, "symfony": { - "require": "^6.4 || ^7.0 || ^8.0" + "require": "^7.4 || ^8.0" }, "thanks": { "name": "api-platform/api-platform", diff --git a/src/Metadata/composer.json b/src/Metadata/composer.json index 792a203f048..c7026b5600f 100644 --- a/src/Metadata/composer.json +++ b/src/Metadata/composer.json @@ -31,9 +31,9 @@ "doctrine/inflector": "^2.0", "psr/cache": "^1.0 || ^2.0 || ^3.0", "psr/log": "^1.0 || ^2.0 || ^3.0", - "symfony/property-info": "^6.4 || ^7.1 || ^8.0", - "symfony/string": "^6.4 || ^7.0 || ^8.0", - "symfony/type-info": "^7.3 || ^8.0" + "symfony/property-info": "^7.4 || ^8.0", + "symfony/string": "^7.4 || ^8.0", + "symfony/type-info": "^7.4 || ^8.0" }, "require-dev": { "api-platform/json-schema": "^4.4@alpha", @@ -42,11 +42,11 @@ "phpspec/prophecy-phpunit": "^2.2", "phpstan/phpdoc-parser": "^1.29 || ^2.0", "phpunit/phpunit": "^11.5 || ^12.2", - "symfony/config": "^6.4 || ^7.0 || ^8.0", - "symfony/routing": "^6.4 || ^7.0 || ^8.0", - "symfony/var-dumper": "^6.4 || ^7.0 || ^8.0", - "symfony/web-link": "^6.4 || ^7.1 || ^8.0", - "symfony/yaml": "^6.4 || ^7.0 || ^8.0" + "symfony/config": "^7.4 || ^8.0", + "symfony/routing": "^7.4 || ^8.0", + "symfony/var-dumper": "^7.4 || ^8.0", + "symfony/web-link": "^7.4 || ^8.0", + "symfony/yaml": "^7.4 || ^8.0" }, "suggest": { "phpstan/phpdoc-parser": "For PHP documentation support.", @@ -79,7 +79,7 @@ "dev-4.1": "4.1.x-dev" }, "symfony": { - "require": "^6.4 || ^7.0 || ^8.0" + "require": "^7.4 || ^8.0" }, "thanks": { "name": "api-platform/api-platform", diff --git a/src/OpenApi/composer.json b/src/OpenApi/composer.json index d0bd2f8a0b9..be00fa29034 100644 --- a/src/OpenApi/composer.json +++ b/src/OpenApi/composer.json @@ -31,11 +31,11 @@ "api-platform/json-schema": "^4.4@alpha", "api-platform/metadata": "^4.4@alpha", "api-platform/state": "^4.4@alpha", - "symfony/console": "^6.4 || ^7.0 || ^8.0", - "symfony/filesystem": "^6.4 || ^7.0 || ^8.0", - "symfony/property-access": "^6.4 || ^7.0 || ^8.0", - "symfony/serializer": "^6.4 || ^7.0 || ^8.0", - "symfony/type-info": "^7.3 || ^8.0" + "symfony/console": "^7.4 || ^8.0", + "symfony/filesystem": "^7.4 || ^8.0", + "symfony/property-access": "^7.4 || ^8.0", + "symfony/serializer": "^7.4 || ^8.0", + "symfony/type-info": "^7.4 || ^8.0" }, "require-dev": { "phpspec/prophecy-phpunit": "^2.2", @@ -44,7 +44,7 @@ "api-platform/doctrine-orm": "^4.4@alpha", "api-platform/doctrine-odm": "^4.4@alpha", "api-platform/serializer": "^4.4@alpha", - "symfony/type-info": "^7.3 || ^8.0" + "symfony/type-info": "^7.4 || ^8.0" }, "autoload": { "psr-4": { @@ -72,7 +72,7 @@ "dev-4.1": "4.1.x-dev" }, "symfony": { - "require": "^6.4 || ^7.0 || ^8.0" + "require": "^7.4 || ^8.0" }, "thanks": { "name": "api-platform/api-platform", diff --git a/src/RamseyUuid/composer.json b/src/RamseyUuid/composer.json index beacdeea745..9a6d4368189 100644 --- a/src/RamseyUuid/composer.json +++ b/src/RamseyUuid/composer.json @@ -24,14 +24,14 @@ "require": { "php": ">=8.2", "api-platform/metadata": "^4.4@alpha", - "symfony/serializer": "^6.4 || ^7.0 || ^8.0" + "symfony/serializer": "^7.4 || ^8.0" }, "require-dev": { "phpspec/prophecy-phpunit": "^2.2", "ramsey/uuid": "^4.7", "ramsey/uuid-doctrine": "^2.0", "phpunit/phpunit": "^11.5 || ^12.2", - "symfony/type-info": "^7.3 || ^8.0" + "symfony/type-info": "^7.4 || ^8.0" }, "autoload": { "psr-4": { @@ -56,7 +56,7 @@ "dev-4.1": "4.1.x-dev" }, "symfony": { - "require": "^6.4 || ^7.0 || ^8.0" + "require": "^7.4 || ^8.0" }, "thanks": { "name": "api-platform/api-platform", diff --git a/src/Serializer/composer.json b/src/Serializer/composer.json index edb2ddc652b..bd45d61a482 100644 --- a/src/Serializer/composer.json +++ b/src/Serializer/composer.json @@ -25,10 +25,10 @@ "php": ">=8.2", "api-platform/metadata": "^4.4@alpha", "api-platform/state": "^4.4@alpha", - "symfony/property-access": "^6.4 || ^7.0 || ^8.0", - "symfony/property-info": "^6.4 || ^7.1 || ^8.0", - "symfony/serializer": "^6.4.37 || ^7.4.9 || ^8.0.9", - "symfony/validator": "^6.4.11 || ^7.0 || ^8.0" + "symfony/property-access": "^7.4 || ^8.0", + "symfony/property-info": "^7.4 || ^8.0", + "symfony/serializer": "^7.4.9 || ^8.0.9", + "symfony/validator": "^7.4 || ^8.0" }, "require-dev": { "api-platform/doctrine-common": "^4.4@alpha", @@ -41,9 +41,9 @@ "phpunit/phpunit": "^11.5 || ^12.2", "sebastian/exporter": "^6.3.2 || ^7.0.2", "symfony/mercure-bundle": "*", - "symfony/var-dumper": "^6.4 || ^7.0 || ^8.0", - "symfony/yaml": "^6.4 || ^7.0 || ^8.0", - "symfony/type-info": "^7.3 || ^8.0" + "symfony/var-dumper": "^7.4 || ^8.0", + "symfony/yaml": "^7.4 || ^8.0", + "symfony/type-info": "^7.4 || ^8.0" }, "suggest": { "api-platform/doctrine-orm": "To support Doctrine ORM state options.", @@ -75,7 +75,7 @@ "dev-4.1": "4.1.x-dev" }, "symfony": { - "require": "^6.4 || ^7.0 || ^8.0" + "require": "^7.4 || ^8.0" }, "thanks": { "name": "api-platform/api-platform", diff --git a/src/State/Util/OperationRequestInitiatorTrait.php b/src/State/Util/OperationRequestInitiatorTrait.php index 4261ece85d3..11a8bd479fd 100644 --- a/src/State/Util/OperationRequestInitiatorTrait.php +++ b/src/State/Util/OperationRequestInitiatorTrait.php @@ -24,9 +24,6 @@ trait OperationRequestInitiatorTrait { private ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null; - /** - * TODO: Kernel terminate remove the _api_operation attribute? - */ private function initializeOperation(Request $request): ?HttpOperation { if ($request->attributes->get('_api_operation')) { diff --git a/src/State/composer.json b/src/State/composer.json index 189311bd2b5..ac27bd2b2a7 100644 --- a/src/State/composer.json +++ b/src/State/composer.json @@ -30,8 +30,8 @@ "php": ">=8.2", "api-platform/metadata": "^4.4@alpha", "psr/container": "^1.0 || ^2.0", - "symfony/http-kernel": "^6.4.13 || ^7.0 || ^8.0", - "symfony/serializer": "^6.4 || ^7.0 || ^8.0", + "symfony/http-kernel": "^7.4 || ^8.0", + "symfony/serializer": "^7.4 || ^8.0", "symfony/translation-contracts": "^3.0", "symfony/deprecation-contracts": "^3.1" }, @@ -39,10 +39,10 @@ "api-platform/serializer": "^4.4@alpha", "api-platform/validator": "^4.4@alpha", "phpunit/phpunit": "^11.5 || ^12.2", - "symfony/http-foundation": "^6.4.14 || ^7.0 || ^8.0", + "symfony/http-foundation": "^7.4 || ^8.0", "symfony/object-mapper": "^7.4 || ^8.0", "symfony/type-info": "^7.4 || ^8.0", - "symfony/web-link": "^6.4 || ^7.1 || ^8.0", + "symfony/web-link": "^7.4 || ^8.0", "willdurand/negotiation": "^3.1" }, "conflicts": { @@ -74,7 +74,7 @@ "dev-4.1": "4.1.x-dev" }, "symfony": { - "require": "^6.4 || ^7.0 || ^8.0" + "require": "^7.4 || ^8.0" }, "thanks": { "name": "api-platform/api-platform", diff --git a/src/Symfony/Validator/State/ParameterValidatorProvider.php b/src/Symfony/Validator/State/ParameterValidatorProvider.php index b814f378dd8..7ce17b5f605 100644 --- a/src/Symfony/Validator/State/ParameterValidatorProvider.php +++ b/src/Symfony/Validator/State/ParameterValidatorProvider.php @@ -88,9 +88,8 @@ public function provide(Operation $operation, array $uriVariables = [], array $c $violation->getInvalidValue(), $violation->getPlural(), $violation->getCode(), - // TODO: remove these with symfony ^7 - method_exists($violation, 'getConstraint') ? $violation->getConstraint() : null, // @phpstan-ignore-line symfony/validator 6.4 is still allowed and this may be true - method_exists($violation, 'getCause') ? $violation->getCause() : null // @phpstan-ignore-line symfony/validator 6.4 is still allowed and this may be true + $violation->getConstraint(), + $violation->getCause() )); } } diff --git a/src/Symfony/composer.json b/src/Symfony/composer.json index 11eff52ca35..55e1122228f 100644 --- a/src/Symfony/composer.json +++ b/src/Symfony/composer.json @@ -39,13 +39,13 @@ "api-platform/state": "^4.4@alpha", "api-platform/validator": "^4.4@alpha", "api-platform/openapi": "^4.4@alpha", - "symfony/asset": "^6.4 || ^7.0 || ^8.0", - "symfony/finder": "^6.4 || ^7.0 || ^8.0", - "symfony/http-kernel": "^6.4.13 || ^7.0 || ^8.0", - "symfony/property-info": "^6.4 || ^7.0 || ^8.0", - "symfony/property-access": "^6.4 || ^7.0 || ^8.0", - "symfony/serializer": "^6.4 || ^7.0 || ^8.0", - "symfony/security-core": "^6.4 || ^7.0 || ^8.0", + "symfony/asset": "^7.4 || ^8.0", + "symfony/finder": "^7.4 || ^8.0", + "symfony/http-kernel": "^7.4 || ^8.0", + "symfony/property-info": "^7.4 || ^8.0", + "symfony/property-access": "^7.4 || ^8.0", + "symfony/serializer": "^7.4 || ^8.0", + "symfony/security-core": "^7.4 || ^8.0", "willdurand/negotiation": "^3.1" }, "require-dev": { @@ -58,13 +58,13 @@ "api-platform/json-api": "^4.4@alpha", "phpspec/prophecy-phpunit": "^2.2", "phpunit/phpunit": "^11.5 || ^12.2", - "symfony/expression-language": "^6.4 || ^7.0 || ^8.0", - "symfony/intl": "^6.4 || ^7.0 || ^8.0", + "symfony/expression-language": "^7.4 || ^8.0", + "symfony/intl": "^7.4 || ^8.0", "symfony/mercure-bundle": "*", - "symfony/object-mapper": "^7.0 || ^8.0", - "symfony/routing": "^6.4 || ^7.0 || ^8.0", - "symfony/type-info": "^7.3 || ^8.0", - "symfony/validator": "^6.4.11 || ^7.0 || ^8.0", + "symfony/object-mapper": "^7.4 || ^8.0", + "symfony/routing": "^7.4 || ^8.0", + "symfony/type-info": "^7.4 || ^8.0", + "symfony/validator": "^7.4 || ^8.0", "webonyx/graphql-php": "^15.0" }, "suggest": { @@ -112,7 +112,7 @@ "dev-main": "4.4.x-dev" }, "symfony": { - "require": "^6.4 || ^7.0 || ^8.0" + "require": "^7.4 || ^8.0" }, "thanks": { "name": "api-platform/api-platform", diff --git a/src/Validator/composer.json b/src/Validator/composer.json index bbe41332fbf..b468e78cc4d 100644 --- a/src/Validator/composer.json +++ b/src/Validator/composer.json @@ -25,11 +25,11 @@ "php": ">=8.2", "api-platform/metadata": "^4.4@alpha", "api-platform/state": "^4.4@alpha", - "symfony/type-info": "^7.3 || ^8.0", - "symfony/http-kernel": "^6.4.13 || ^7.1 || ^8.0", - "symfony/serializer": "^6.4 || ^7.1 || ^8.0", - "symfony/validator": "^6.4.11 || ^7.1 || ^8.0", - "symfony/web-link": "^6.4 || ^7.1 || ^8.0" + "symfony/type-info": "^7.4 || ^8.0", + "symfony/http-kernel": "^7.4 || ^8.0", + "symfony/serializer": "^7.4 || ^8.0", + "symfony/validator": "^7.4 || ^8.0", + "symfony/web-link": "^7.4 || ^8.0" }, "require-dev": { "phpspec/prophecy-phpunit": "^2.2", @@ -58,7 +58,7 @@ "dev-4.1": "4.1.x-dev" }, "symfony": { - "require": "^6.4 || ^7.0 || ^8.0" + "require": "^7.4 || ^8.0" }, "thanks": { "name": "api-platform/api-platform", From 88f458a1108ba2fcd052a58d7647f23dad1b186b Mon Sep 17 00:00:00 2001 From: soyuka Date: Sun, 12 Jul 2026 11:03:18 +0200 Subject: [PATCH 23/25] fix(jsonschema): drop removed getBuiltinTypes path in SchemaPropertyMetadataFactory The 4.4 readable-link refinement used a getType-gated dual path; main (5.0) removed ApiProperty::getBuiltinTypes and property-info's getType, so on the bumped Symfony floor the legacy branch fatally called an undefined method during cache warmup (breaking every PHPUnit job). Keep only the native-type branch, matching main's modern type handling. --- .../Factory/SchemaPropertyMetadataFactory.php | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/src/JsonSchema/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php b/src/JsonSchema/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php index 8abeca0724a..50c2b66de7d 100644 --- a/src/JsonSchema/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php +++ b/src/JsonSchema/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php @@ -73,23 +73,8 @@ public function create(string $resourceClass, string $property, array $options = // on output a non-resource object is serialized by the standard object normalizer, which embeds non-resource properties regardless of readableLink (see AbstractItemNormalizer::supportsNormalization()) // For resource-typed properties however, the circular reference handler (see AbstractItemNormalizer::$defaultContext) may produce an IRI, so isReadableLink should determine the schema if (!$isInput && !$this->isResourceClass($resourceClass)) { - if (method_exists(PropertyInfoExtractor::class, 'getType')) { - if (!$propertyMetadata->getNativeType()?->isSatisfiedBy(fn (Type $t) => $t instanceof ObjectType && $this->resourceClassResolver->isResourceClass($t->getClassName()))) { - $link = true; - } - } else { - $propertyTypeIsResource = false; - foreach ($propertyMetadata->getBuiltinTypes() ?? [] as $builtinType) { - $className = $builtinType->isCollection() ? ($builtinType->getCollectionValueTypes()[0] ?? null)?->getClassName() : $builtinType->getClassName(); - if ($className && $this->resourceClassResolver->isResourceClass($className)) { - $propertyTypeIsResource = true; - break; - } - } - - if (!$propertyTypeIsResource) { - $link = true; - } + if (!$propertyMetadata->getNativeType()?->isSatisfiedBy(fn (Type $t) => $t instanceof ObjectType && $this->resourceClassResolver->isResourceClass($t->getClassName()))) { + $link = true; } } From 8360186eda109c3e3ed2dd22d054b6ce6cb7404a Mon Sep 17 00:00:00 2001 From: soyuka Date: Sun, 12 Jul 2026 11:20:36 +0200 Subject: [PATCH 24/25] fix(serializer): remove dead native-type guards in AbstractItemNormalizerTest The #8393 nullable denormalization tests guarded on method_exists(PropertyInfoExtractor::class, 'getType') with an unqualified class (resolving to the test namespace), so they were always skipped and PHPStan flagged the call as always-false. With the Symfony ^7.4 floor native types are guaranteed; drop the guards so the tests actually run. --- .../Tests/AbstractItemNormalizerTest.php | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/src/Serializer/Tests/AbstractItemNormalizerTest.php b/src/Serializer/Tests/AbstractItemNormalizerTest.php index 6fd17c1ce73..673a415ae8d 100644 --- a/src/Serializer/Tests/AbstractItemNormalizerTest.php +++ b/src/Serializer/Tests/AbstractItemNormalizerTest.php @@ -1193,12 +1193,6 @@ public function testUnionTypeCollectionDenormalizationAcceptsAnyMember(): void public function testDenormalizeNullableCollectionOfBackedEnums(): void { - // Nullable collection value types (NullableType wrapping ObjectType/BackedEnumType) only exist - // in the native TypeInfo system. - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $this->markTestSkipped('Requires symfony/property-info >= 7.1 (native types).'); - } - $data = ['notificationType' => ['email']]; $propertyNameCollectionFactory = $this->createStub(PropertyNameCollectionFactoryInterface::class); @@ -1244,11 +1238,6 @@ public function testDenormalizeNullableCollectionOfBackedEnums(): void public function testDenormalizeWrongTypedValueForNullableObjectPropertyPreservesNormalizerException(): void { - // Nullable object types (NullableType wrapping ObjectType) only exist in the native TypeInfo system. - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $this->markTestSkipped('Requires symfony/property-info >= 7.1 (native types).'); - } - // What Symfony's DateTimeNormalizer throws for a value it cannot parse. $normalizerException = NotNormalizableValueException::createForUnexpectedDataType('The data is either not an string, an empty string, or null; you should pass a string that can be parsed with the passed format or a valid DateTime string.', false, ['string'], 'dummyDate', true); @@ -1267,9 +1256,6 @@ public function testDenormalizeWrongTypedValueForNullableObjectPropertyPreserves public function testDenormalizeWrongTypedValueForNonNullableObjectPropertyPreservesNormalizerException(): void { - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $this->markTestSkipped('Requires symfony/property-info >= 7.1 (native types).'); - } $normalizerException = NotNormalizableValueException::createForUnexpectedDataType('The data is either not an string, an empty string, or null; you should pass a string that can be parsed with the passed format or a valid DateTime string.', false, ['string'], 'dummyDate', true); From bc387cd093343adcc20a9001095a0c710ea13432 Mon Sep 17 00:00:00 2001 From: soyuka Date: Sun, 12 Jul 2026 11:31:21 +0200 Subject: [PATCH 25/25] style: cs-fixer --- src/Serializer/Tests/AbstractItemNormalizerTest.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Serializer/Tests/AbstractItemNormalizerTest.php b/src/Serializer/Tests/AbstractItemNormalizerTest.php index 673a415ae8d..ff54a251511 100644 --- a/src/Serializer/Tests/AbstractItemNormalizerTest.php +++ b/src/Serializer/Tests/AbstractItemNormalizerTest.php @@ -1256,7 +1256,6 @@ public function testDenormalizeWrongTypedValueForNullableObjectPropertyPreserves public function testDenormalizeWrongTypedValueForNonNullableObjectPropertyPreservesNormalizerException(): void { - $normalizerException = NotNormalizableValueException::createForUnexpectedDataType('The data is either not an string, an empty string, or null; you should pass a string that can be parsed with the passed format or a valid DateTime string.', false, ['string'], 'dummyDate', true); $normalizer = $this->createNormalizerForObjectProperty('dummyDate', Type::object(\DateTimeImmutable::class), \DateTimeImmutable::class, $normalizerException);