From 244dfea0698e2b6ad46b29d07b5612f427ff6e45 Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 28 May 2026 16:25:42 +0200 Subject: [PATCH 1/2] fix(symfony): isolate api_platform.property_info tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #7969 (shipped in 4.3.6) turned `api_platform.property_info` from an alias of Symfony's `property_info` into a self-contained service that registered `ReflectionExtractor`, `PhpDocExtractor`, and `PhpStanExtractor` tagged with the public `property_info.*` namespace. Two problems followed: - Symfony's framework `property_info` consumes the same public tag iterator and therefore inherited our `PhpStanExtractor` too — including in `validator.property_info_loader`. On projects with Doctrine interfaces carrying `@template T of object` (e.g. Sylius), `bin/console cache:clear` crashed inside `PhpStanExtractor::getType` with "Cannot create union with both \"object\" and class type." - Tagging our extractors with only a private namespace would have fixed the leak but would also have lost inheritance of third-party-registered extractors (notably Doctrine's `DoctrineExtractor`), breaking serialization and validation (embedded objects vs IRIs, "array given" on persist, missing constraint violations). Fix: - Tag API Platform's 3 fallback extractors with private `api_platform.property_info.*` tags only. - `api_platform.property_info` consumes the private tag iterators. - Add `PropertyInfoTagPass`, a one-way bridge compiler pass that copies every public `property_info.{suffix}` tag (preserving attributes such as priority) onto the same service as `api_platform.property_info.{suffix}`, skipping services already carrying the private tag. Result: framework `property_info` is untouched (public tags only, no API Platform extractors leak in), while `api_platform.property_info` still inherits framework + Doctrine + third-party extractors via the bridged private tags. The #7876 fallback path stays intact when framework's `property_info` is absent. Fixes #8201 Refs #7969 #7876 --- src/Symfony/Bundle/ApiPlatformBundle.php | 5 ++ .../Compiler/PropertyInfoTagPass.php | 57 +++++++++++++++++ src/Symfony/Bundle/Resources/config/api.php | 24 +++---- .../ApiPlatformExtensionTest.php | 32 ++++++++++ .../Symfony/Bundle/ApiPlatformBundleTest.php | 2 + .../Compiler/PropertyInfoTagPassTest.php | 62 +++++++++++++++++++ 6 files changed, 170 insertions(+), 12 deletions(-) create mode 100644 src/Symfony/Bundle/DependencyInjection/Compiler/PropertyInfoTagPass.php create mode 100644 tests/Symfony/Bundle/DependencyInjection/Compiler/PropertyInfoTagPassTest.php diff --git a/src/Symfony/Bundle/ApiPlatformBundle.php b/src/Symfony/Bundle/ApiPlatformBundle.php index b4749b48d7..2a79a99da2 100644 --- a/src/Symfony/Bundle/ApiPlatformBundle.php +++ b/src/Symfony/Bundle/ApiPlatformBundle.php @@ -24,6 +24,7 @@ use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\JsonStreamerTransformerPass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\MetadataAwareNameConverterPass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\MutatorPass; +use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\PropertyInfoTagPass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\SerializerMappingLoaderPass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\TestClientPass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\TestMercureHubPass; @@ -60,6 +61,10 @@ public function build(ContainerBuilder $container): void $container->addCompilerPass(new AuthenticatorManagerPass()); $container->addCompilerPass(new SerializerMappingLoaderPass()); $container->addCompilerPass(new MutatorPass()); + // Bridges Symfony's public `property_info.*` tags to API Platform's private + // `api_platform.property_info.*` tags. Runs late in TYPE_BEFORE_OPTIMIZATION + // so framework-/third-party-registered extractors are already tagged. + $container->addCompilerPass(new PropertyInfoTagPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -100); // Must run after Symfony's TransformerPass so we can rely on the value_object_transformer tag being processed. $container->addCompilerPass(new JsonStreamerTransformerPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -10); } diff --git a/src/Symfony/Bundle/DependencyInjection/Compiler/PropertyInfoTagPass.php b/src/Symfony/Bundle/DependencyInjection/Compiler/PropertyInfoTagPass.php new file mode 100644 index 0000000000..42c5a15f39 --- /dev/null +++ b/src/Symfony/Bundle/DependencyInjection/Compiler/PropertyInfoTagPass.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\Symfony\Bundle\DependencyInjection\Compiler; + +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; + +/** + * Bridges Symfony's public `property_info.*` tags to API Platform's private + * `api_platform.property_info.*` tags so that `api_platform.property_info` + * inherits framework- and third-party-registered extractors (e.g. Doctrine's + * `DoctrineExtractor`) without API Platform's own extractors leaking back into + * Symfony's `property_info` service. + * + * @internal + * + * @see https://github.com/api-platform/core/issues/8201 + */ +final class PropertyInfoTagPass implements CompilerPassInterface +{ + private const TAG_SUFFIXES = [ + 'list_extractor', + 'type_extractor', + 'description_extractor', + 'access_extractor', + 'initializable_extractor', + ]; + + public function process(ContainerBuilder $container): void + { + foreach (self::TAG_SUFFIXES as $suffix) { + $publicTag = 'property_info.'.$suffix; + $privateTag = 'api_platform.property_info.'.$suffix; + + foreach ($container->findTaggedServiceIds($publicTag) as $serviceId => $tags) { + $definition = $container->getDefinition($serviceId); + if ($definition->hasTag($privateTag)) { + continue; + } + foreach ($tags as $attributes) { + $definition->addTag($privateTag, $attributes); + } + } + } + } +} diff --git a/src/Symfony/Bundle/Resources/config/api.php b/src/Symfony/Bundle/Resources/config/api.php index 60ca7413de..586e2988eb 100644 --- a/src/Symfony/Bundle/Resources/config/api.php +++ b/src/Symfony/Bundle/Resources/config/api.php @@ -77,29 +77,29 @@ $services->alias('api_platform.property_accessor', 'property_accessor'); $services->set('api_platform.property_info.reflection_extractor', ReflectionExtractor::class) - ->tag('property_info.list_extractor', ['priority' => -1000]) - ->tag('property_info.type_extractor', ['priority' => -1002]) - ->tag('property_info.access_extractor', ['priority' => -1000]) - ->tag('property_info.initializable_extractor', ['priority' => -1000]); + ->tag('api_platform.property_info.list_extractor', ['priority' => -1000]) + ->tag('api_platform.property_info.type_extractor', ['priority' => -1002]) + ->tag('api_platform.property_info.access_extractor', ['priority' => -1000]) + ->tag('api_platform.property_info.initializable_extractor', ['priority' => -1000]); if (class_exists(DocBlockFactory::class)) { $services->set('api_platform.property_info.php_doc_extractor', PhpDocExtractor::class) - ->tag('property_info.description_extractor', ['priority' => -1000]) - ->tag('property_info.type_extractor', ['priority' => -1001]); + ->tag('api_platform.property_info.description_extractor', ['priority' => -1000]) + ->tag('api_platform.property_info.type_extractor', ['priority' => -1001]); } if (class_exists(PhpDocParser::class) && class_exists(ContextFactory::class)) { $services->set('api_platform.property_info.phpstan_extractor', PhpStanExtractor::class) - ->tag('property_info.type_extractor', ['priority' => -1000]); + ->tag('api_platform.property_info.type_extractor', ['priority' => -1000]); } $services->set('api_platform.property_info', PropertyInfoExtractor::class) ->args([ - tagged_iterator('property_info.list_extractor'), - tagged_iterator('property_info.type_extractor'), - tagged_iterator('property_info.description_extractor'), - tagged_iterator('property_info.access_extractor'), - tagged_iterator('property_info.initializable_extractor'), + tagged_iterator('api_platform.property_info.list_extractor'), + tagged_iterator('api_platform.property_info.type_extractor'), + tagged_iterator('api_platform.property_info.description_extractor'), + tagged_iterator('api_platform.property_info.access_extractor'), + tagged_iterator('api_platform.property_info.initializable_extractor'), ]); $services->set('api_platform.property_info.cache', PropertyInfoCacheExtractor::class) diff --git a/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index a17bd5c41c..ee7e3abe7f 100644 --- a/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -412,4 +412,36 @@ public function testPaginationMaximumItemsPerPageWhenDefaultsKeyIsMissing(): voi $this->assertTrue($this->container->hasParameter('api_platform.collection.pagination.maximum_items_per_page')); $this->assertSame(30, $this->container->getParameter('api_platform.collection.pagination.maximum_items_per_page')); } + + /** + * @see https://github.com/api-platform/core/issues/8201 + */ + public function testPropertyInfoExtractorsDoNotLeakIntoFrameworkPropertyInfo(): void + { + $config = self::DEFAULT_CONFIG; + (new ApiPlatformExtension())->load($config, $this->container); + + $services = ['api_platform.property_info.reflection_extractor']; + if (class_exists(\phpDocumentor\Reflection\DocBlockFactory::class)) { + $services[] = 'api_platform.property_info.php_doc_extractor'; + } + if (class_exists(\PHPStan\PhpDocParser\Parser\PhpDocParser::class) && class_exists(\phpDocumentor\Reflection\Types\ContextFactory::class)) { + $services[] = 'api_platform.property_info.phpstan_extractor'; + } + + foreach ($services as $service) { + $this->assertContainerHasService($service); + $tags = $this->container->getDefinition($service)->getTags(); + foreach ($tags as $name => $_) { + $this->assertStringStartsNotWith('property_info.', $name, \sprintf('Service "%s" must not use the global "property_info.*" tag namespace (leaks into Symfony\'s property_info and breaks the validator chain — issue #8201). Found tag "%s".', $service, $name)); + } + } + + $apiPlatformPropertyInfo = $this->container->getDefinition('api_platform.property_info'); + foreach ($apiPlatformPropertyInfo->getArguments() as $arg) { + if ($arg instanceof \Symfony\Component\DependencyInjection\Argument\TaggedIteratorArgument) { + $this->assertStringStartsWith('api_platform.property_info.', $arg->getTag(), \sprintf('api_platform.property_info must consume only "api_platform.property_info.*" private tags; found "%s".', $arg->getTag())); + } + } + } } diff --git a/tests/Symfony/Bundle/ApiPlatformBundleTest.php b/tests/Symfony/Bundle/ApiPlatformBundleTest.php index 660f12243d..adcfda87f2 100644 --- a/tests/Symfony/Bundle/ApiPlatformBundleTest.php +++ b/tests/Symfony/Bundle/ApiPlatformBundleTest.php @@ -25,6 +25,7 @@ use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\JsonStreamerTransformerPass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\MetadataAwareNameConverterPass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\MutatorPass; +use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\PropertyInfoTagPass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\SerializerMappingLoaderPass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\TestClientPass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\TestMercureHubPass; @@ -59,6 +60,7 @@ public function testBuild(): void $this->assertContains(AuthenticatorManagerPass::class, $passClasses); $this->assertContains(SerializerMappingLoaderPass::class, $passClasses); $this->assertContains(MutatorPass::class, $passClasses); + $this->assertContains(PropertyInfoTagPass::class, $passClasses); $this->assertContains(JsonStreamerTransformerPass::class, $passClasses); } } diff --git a/tests/Symfony/Bundle/DependencyInjection/Compiler/PropertyInfoTagPassTest.php b/tests/Symfony/Bundle/DependencyInjection/Compiler/PropertyInfoTagPassTest.php new file mode 100644 index 0000000000..40a8dea514 --- /dev/null +++ b/tests/Symfony/Bundle/DependencyInjection/Compiler/PropertyInfoTagPassTest.php @@ -0,0 +1,62 @@ + + * + * 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\Symfony\Bundle\DependencyInjection\Compiler; + +use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\PropertyInfoTagPass; +use PHPUnit\Framework\TestCase; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Definition; + +/** + * @see https://github.com/api-platform/core/issues/8201 + */ +final class PropertyInfoTagPassTest extends TestCase +{ + public function testBridgesPublicTagsToPrivateNamespace(): void + { + $container = new ContainerBuilder(); + $container->setDefinition('framework.reflection_extractor', (new Definition(\stdClass::class)) + ->addTag('property_info.type_extractor', ['priority' => -100]) + ->addTag('property_info.list_extractor')); + + (new PropertyInfoTagPass())->process($container); + + $definition = $container->getDefinition('framework.reflection_extractor'); + $this->assertSame([['priority' => -100]], $definition->getTag('api_platform.property_info.type_extractor')); + $this->assertSame([[]], $definition->getTag('api_platform.property_info.list_extractor')); + } + + public function testSkipsServicesAlreadyCarryingThePrivateTag(): void + { + $container = new ContainerBuilder(); + $container->setDefinition('api_platform.property_info.reflection_extractor', (new Definition(\stdClass::class)) + ->addTag('property_info.type_extractor', ['priority' => -1002]) + ->addTag('api_platform.property_info.type_extractor', ['priority' => -1002])); + + (new PropertyInfoTagPass())->process($container); + + $tags = $container->getDefinition('api_platform.property_info.reflection_extractor')->getTag('api_platform.property_info.type_extractor'); + $this->assertCount(1, $tags, 'Pass must not re-tag services that already carry the private tag.'); + } + + public function testDoesNotTagServicesWithoutPublicTags(): void + { + $container = new ContainerBuilder(); + $container->setDefinition('unrelated_service', new Definition(\stdClass::class)); + + (new PropertyInfoTagPass())->process($container); + + $this->assertSame([], $container->getDefinition('unrelated_service')->getTags()); + } +} From 86388cb0cd06578eb396b04f989f58259547372c Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 29 May 2026 09:06:26 +0200 Subject: [PATCH 2/2] Update src/Symfony/Bundle/ApiPlatformBundle.php --- src/Symfony/Bundle/ApiPlatformBundle.php | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/Symfony/Bundle/ApiPlatformBundle.php b/src/Symfony/Bundle/ApiPlatformBundle.php index 2a79a99da2..d89d766490 100644 --- a/src/Symfony/Bundle/ApiPlatformBundle.php +++ b/src/Symfony/Bundle/ApiPlatformBundle.php @@ -61,9 +61,6 @@ public function build(ContainerBuilder $container): void $container->addCompilerPass(new AuthenticatorManagerPass()); $container->addCompilerPass(new SerializerMappingLoaderPass()); $container->addCompilerPass(new MutatorPass()); - // Bridges Symfony's public `property_info.*` tags to API Platform's private - // `api_platform.property_info.*` tags. Runs late in TYPE_BEFORE_OPTIMIZATION - // so framework-/third-party-registered extractors are already tagged. $container->addCompilerPass(new PropertyInfoTagPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -100); // Must run after Symfony's TransformerPass so we can rely on the value_object_transformer tag being processed. $container->addCompilerPass(new JsonStreamerTransformerPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -10);