From 9d55ac39f91a5a311cc708a4b7fd952f5d171b33 Mon Sep 17 00:00:00 2001 From: Sebastian Breuers Date: Tue, 27 Jan 2026 09:41:59 +0100 Subject: [PATCH 1/5] refactor: improve constructor arguments legibility --- src/JsonApi/Serializer/ItemNormalizer.php | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/JsonApi/Serializer/ItemNormalizer.php b/src/JsonApi/Serializer/ItemNormalizer.php index 4b3084db27..a701af9b41 100644 --- a/src/JsonApi/Serializer/ItemNormalizer.php +++ b/src/JsonApi/Serializer/ItemNormalizer.php @@ -59,7 +59,19 @@ final class ItemNormalizer extends AbstractItemNormalizer private array $componentsCache = []; - public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, ?PropertyAccessorInterface $propertyAccessor = null, ?NameConverterInterface $nameConverter = null, ?ClassMetadataFactoryInterface $classMetadataFactory = null, array $defaultContext = [], ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, ?ResourceAccessCheckerInterface $resourceAccessChecker = null, protected ?TagCollectorInterface $tagCollector = null) + public function __construct( + PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, + PropertyMetadataFactoryInterface $propertyMetadataFactory, + IriConverterInterface $iriConverter, + ResourceClassResolverInterface $resourceClassResolver, + ?PropertyAccessorInterface $propertyAccessor = null, + ?NameConverterInterface $nameConverter = null, + ?ClassMetadataFactoryInterface $classMetadataFactory = null, + array $defaultContext = [], + ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, + ?ResourceAccessCheckerInterface $resourceAccessChecker = null, + protected ?TagCollectorInterface $tagCollector = null + ) { parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $defaultContext, $resourceMetadataCollectionFactory, $resourceAccessChecker, $tagCollector); } From 76dfef3588d48f0e476f2692ccb73bdde56e4ea8 Mon Sep 17 00:00:00 2001 From: Sebastian Breuers Date: Tue, 27 Jan 2026 09:42:47 +0100 Subject: [PATCH 2/5] feature: allow to use identifiers instead of IRIs in JSONAPI The JSONAPI spec combines a type and an id to uniquely identify a resource: https://jsonapi.org/format/#document-resource-object-identification The current api platform implementation uses the IRI as the ID which is not JSONAPI compliant. --- src/JsonApi/Serializer/ItemNormalizer.php | 78 +++++++++++++++++-- .../Bundle/Resources/config/jsonapi.php | 2 + 2 files changed, 72 insertions(+), 8 deletions(-) diff --git a/src/JsonApi/Serializer/ItemNormalizer.php b/src/JsonApi/Serializer/ItemNormalizer.php index a701af9b41..6692879a11 100644 --- a/src/JsonApi/Serializer/ItemNormalizer.php +++ b/src/JsonApi/Serializer/ItemNormalizer.php @@ -15,6 +15,7 @@ use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\Exception\ItemNotFoundException; +use ApiPlatform\Metadata\IdentifiersExtractorInterface; use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; @@ -28,6 +29,7 @@ use ApiPlatform\Serializer\CacheKeyTrait; use ApiPlatform\Serializer\ContextTrait; use ApiPlatform\Serializer\TagCollectorInterface; +use ApiPlatform\State\ProviderInterface; use Symfony\Component\ErrorHandler\Exception\FlattenException; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\Component\PropertyInfo\PropertyInfoExtractor; @@ -70,10 +72,14 @@ public function __construct( array $defaultContext = [], ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, ?ResourceAccessCheckerInterface $resourceAccessChecker = null, - protected ?TagCollectorInterface $tagCollector = null + protected ?TagCollectorInterface $tagCollector = null, + private readonly IdentifiersExtractorInterface $identifiersExtractor, + private readonly ProviderInterface $provider, + string $resourceIdStrategy = 'iri' ) { parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $defaultContext, $resourceMetadataCollectionFactory, $resourceAccessChecker, $tagCollector); + $this->resourceIdStrategy = $resourceIdStrategy; } /** @@ -137,8 +143,14 @@ public function normalize(mixed $object, ?string $format = null, array $context $includedResourcesData = $this->getRelatedResources($object, $format, $context, $allRelationshipsData); + $id = $context['iri']; + if ($this->resourceIdStrategy === 'identifiers') { + $identifiers = $this->identifiersExtractor->getIdentifiersFromItem($object); + $id = (string) array_values($identifiers)[0]; + } + $resourceData = [ - 'id' => $context['iri'], + 'id' => $id, 'type' => $this->getResourceShortName($resourceClass), ]; @@ -186,10 +198,21 @@ public function denormalize(mixed $data, string $class, ?string $format = null, throw new NotNormalizableValueException('Update is not allowed for this operation.'); } - $context[self::OBJECT_TO_POPULATE] = $this->iriConverter->getResourceFromIri( - $data['data']['id'], - $context + ['fetch_data' => false] - ); + $context += ['fetch_data' => false]; + if ($this->resourceIdStrategy === 'iri') { + $context[self::OBJECT_TO_POPULATE] = $this->iriConverter->getResourceFromIri( + $data['data']['id'], + $context + ); + } else if ($this->resourceIdStrategy === 'identifiers') { + $operation = $context['operation'] ?? $context['api_platform_operation'] ?? null; + $uriVariables = $context['uriVariables'] ?? []; + $context[self::OBJECT_TO_POPULATE] = $this->provider->provide( + $operation, + $uriVariables, + $context + ); + } } // Merge attributes and relationships, into format expected by the parent normalizer @@ -237,7 +260,41 @@ protected function denormalizeRelation(string $attributeName, ApiProperty $prope } try { - return $this->iriConverter->getResourceFromIri($value['id'], $context + ['fetch_data' => true]); + $context += ['fetch_data' => true]; + if ($this->resourceIdStrategy === 'iri') { + return $this->iriConverter->getResourceFromIri($value['id'], $context); + } else if ($this->resourceIdStrategy === 'identifiers') { + $targetClass = $propertyMetadata->getBuiltinTypes()[0]->getClassName(); + $resourceMetadata = $this->resourceMetadataCollectionFactory + ->create($targetClass); + + $getOperation = null; + foreach ($resourceMetadata as $resource) { + foreach ($resource->getOperations() as $operation) { + if ($operation instanceof Get) { + $getOperation = $operation; + break 2; + } + } + } + if (null === $getOperation) { + throw new ItemNotFoundException(sprintf( + 'No GET operation found for resource "%s".', + $targetClass + )); + } + + $uriVariablesDefinition = $getOperation->getUriVariables(); + + $uriVariables = []; + foreach ($uriVariablesDefinition as $varName => $link) { + foreach ($link->getIdentifiers() as $identifier) { + $uriVariables[$identifier] = (string) $value['data']['id']; + } + } + return $this->provider->provide($getOperation, $uriVariables, $context); + } + } catch (ItemNotFoundException $e) { if (!isset($context['not_normalizable_value_exceptions'])) { throw new RuntimeException($e->getMessage(), $e->getCode(), $e); @@ -285,10 +342,15 @@ protected function normalizeRelation(ApiProperty $propertyMetadata, ?object $rel return $normalizedRelatedObject; } + $id = $iri; + if ($this->resourceIdStrategy === 'identifiers') { + $identifiers = $this->identifiersExtractor->getIdentifiersFromItem($relatedObject); + $id = (string) array_values($identifiers)[0]; + } $context['data'] = [ 'data' => [ + 'id' => $id, 'type' => $this->getResourceShortName($resourceClass), - 'id' => $iri, ], ]; diff --git a/src/Symfony/Bundle/Resources/config/jsonapi.php b/src/Symfony/Bundle/Resources/config/jsonapi.php index 4e760e4963..8605b662b8 100644 --- a/src/Symfony/Bundle/Resources/config/jsonapi.php +++ b/src/Symfony/Bundle/Resources/config/jsonapi.php @@ -62,6 +62,8 @@ service('api_platform.metadata.resource.metadata_collection_factory'), service('api_platform.security.resource_access_checker')->ignoreOnInvalid(), service('api_platform.http_cache.tag_collector')->ignoreOnInvalid(), + service('api_platform.api.identifiers_extractor'), + service('api_platform.state_provider'), ]) ->tag('serializer.normalizer', ['priority' => -890]); From 4841873bd4185b1590668fc8ac5fecbb4c72ab36 Mon Sep 17 00:00:00 2001 From: Sebastian Breuers Date: Tue, 27 Jan 2026 09:47:58 +0100 Subject: [PATCH 3/5] feature: control resource id strategy in JsonApi serializer via configuration --- .../Bundle/DependencyInjection/ApiPlatformExtension.php | 7 +++++-- src/Symfony/Bundle/DependencyInjection/Configuration.php | 9 +++++++++ .../Bundle/DependencyInjection/ConfigurationTest.php | 3 +++ 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index 2d23932693..c34f9109f9 100644 --- a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -159,7 +159,7 @@ public function load(array $configs, ContainerBuilder $container): void $this->registerOAuthConfiguration($container, $config); $this->registerOpenApiConfiguration($container, $config, $loader); $this->registerSwaggerConfiguration($container, $config, $loader); - $this->registerJsonApiConfiguration($formats, $loader, $config); + $this->registerJsonApiConfiguration($container, $formats, $loader, $config); $this->registerJsonLdHydraConfiguration($container, $formats, $loader, $config); $this->registerJsonHalConfiguration($formats, $loader); $this->registerJsonProblemConfiguration($errorFormats, $loader); @@ -630,7 +630,7 @@ private function registerSwaggerConfiguration(ContainerBuilder $container, array $container->setParameter('api_platform.swagger_ui.extra_configuration', $config['openapi']['swagger_ui_extra_configuration'] ?: $config['swagger']['swagger_ui_extra_configuration']); } - private function registerJsonApiConfiguration(array $formats, PhpFileLoader $loader, array $config): void + private function registerJsonApiConfiguration(ContainerBuilder $container, array $formats, PhpFileLoader $loader, array $config): void { if (!isset($formats['jsonapi'])) { return; @@ -642,6 +642,9 @@ private function registerJsonApiConfiguration(array $formats, PhpFileLoader $loa $loader->load('jsonapi.php'); $loader->load('state/jsonapi.php'); + + $container->getDefinition('api_platform.jsonapi.normalizer.item') + ->addArgument($config['jsonapi']['resource_id_strategy']); } private function registerJsonLdHydraConfiguration(ContainerBuilder $container, array $formats, PhpFileLoader $loader, array $config): void diff --git a/src/Symfony/Bundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/DependencyInjection/Configuration.php index 354b7b1f2d..72335563ca 100644 --- a/src/Symfony/Bundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/DependencyInjection/Configuration.php @@ -99,6 +99,15 @@ public function getConfigTreeBuilder(): TreeBuilder ->end() ->end() ->end() + ->arrayNode('jsonapi') + ->addDefaultsIfNotSet() + ->children() + ->enumNode('resource_id_strategy') + ->values(['iri', 'identifiers']) + ->defaultValue('iri') + ->end() + ->end() + ->end() ->arrayNode('eager_loading') ->canBeDisabled() ->addDefaultsIfNotSet() diff --git a/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php b/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php index 62c8cef82d..1f74cf6bcb 100644 --- a/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php +++ b/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php @@ -235,6 +235,9 @@ private function runDefaultConfigTests(array $doctrineIntegrationsToLoad = ['orm 'hydra_prefix' => null, ], 'enable_phpdoc_parser' => true, + 'jsonapi' => [ + 'resource_id_strategy' => 'iri' + ], ], $config); } From baa2ffbde46a8784001078663ae359f84db3d7c1 Mon Sep 17 00:00:00 2001 From: Sebastian Breuers Date: Thu, 29 Jan 2026 14:23:42 +0100 Subject: [PATCH 4/5] refactor: use boolean to configure usage of IRI as id --- src/JsonApi/Serializer/ItemNormalizer.php | 16 ++++++++-------- .../DependencyInjection/ApiPlatformExtension.php | 2 +- .../Bundle/DependencyInjection/Configuration.php | 5 ++--- .../DependencyInjection/ConfigurationTest.php | 2 +- 4 files changed, 12 insertions(+), 13 deletions(-) diff --git a/src/JsonApi/Serializer/ItemNormalizer.php b/src/JsonApi/Serializer/ItemNormalizer.php index 6692879a11..08a52b3a7d 100644 --- a/src/JsonApi/Serializer/ItemNormalizer.php +++ b/src/JsonApi/Serializer/ItemNormalizer.php @@ -75,11 +75,11 @@ public function __construct( protected ?TagCollectorInterface $tagCollector = null, private readonly IdentifiersExtractorInterface $identifiersExtractor, private readonly ProviderInterface $provider, - string $resourceIdStrategy = 'iri' + bool $useIriAsId = true ) { parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $defaultContext, $resourceMetadataCollectionFactory, $resourceAccessChecker, $tagCollector); - $this->resourceIdStrategy = $resourceIdStrategy; + $this->useIriAsId = $useIriAsId; } /** @@ -144,7 +144,7 @@ public function normalize(mixed $object, ?string $format = null, array $context $includedResourcesData = $this->getRelatedResources($object, $format, $context, $allRelationshipsData); $id = $context['iri']; - if ($this->resourceIdStrategy === 'identifiers') { + if (!$this->useIriAsId) { $identifiers = $this->identifiersExtractor->getIdentifiersFromItem($object); $id = (string) array_values($identifiers)[0]; } @@ -199,12 +199,12 @@ public function denormalize(mixed $data, string $class, ?string $format = null, } $context += ['fetch_data' => false]; - if ($this->resourceIdStrategy === 'iri') { + if ($this->useIriAsId) { $context[self::OBJECT_TO_POPULATE] = $this->iriConverter->getResourceFromIri( $data['data']['id'], $context ); - } else if ($this->resourceIdStrategy === 'identifiers') { + } else { $operation = $context['operation'] ?? $context['api_platform_operation'] ?? null; $uriVariables = $context['uriVariables'] ?? []; $context[self::OBJECT_TO_POPULATE] = $this->provider->provide( @@ -261,9 +261,9 @@ protected function denormalizeRelation(string $attributeName, ApiProperty $prope try { $context += ['fetch_data' => true]; - if ($this->resourceIdStrategy === 'iri') { + if ($this->useIriAsId) { return $this->iriConverter->getResourceFromIri($value['id'], $context); - } else if ($this->resourceIdStrategy === 'identifiers') { + } else { $targetClass = $propertyMetadata->getBuiltinTypes()[0]->getClassName(); $resourceMetadata = $this->resourceMetadataCollectionFactory ->create($targetClass); @@ -343,7 +343,7 @@ protected function normalizeRelation(ApiProperty $propertyMetadata, ?object $rel } $id = $iri; - if ($this->resourceIdStrategy === 'identifiers') { + if (!$this->useIriAsId) { $identifiers = $this->identifiersExtractor->getIdentifiersFromItem($relatedObject); $id = (string) array_values($identifiers)[0]; } diff --git a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index c34f9109f9..67058ced40 100644 --- a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -644,7 +644,7 @@ private function registerJsonApiConfiguration(ContainerBuilder $container, array $loader->load('state/jsonapi.php'); $container->getDefinition('api_platform.jsonapi.normalizer.item') - ->addArgument($config['jsonapi']['resource_id_strategy']); + ->addArgument($config['jsonapi']['use_iri_as_id']); } private function registerJsonLdHydraConfiguration(ContainerBuilder $container, array $formats, PhpFileLoader $loader, array $config): void diff --git a/src/Symfony/Bundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/DependencyInjection/Configuration.php index 72335563ca..fc5ed6dacd 100644 --- a/src/Symfony/Bundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/DependencyInjection/Configuration.php @@ -102,9 +102,8 @@ public function getConfigTreeBuilder(): TreeBuilder ->arrayNode('jsonapi') ->addDefaultsIfNotSet() ->children() - ->enumNode('resource_id_strategy') - ->values(['iri', 'identifiers']) - ->defaultValue('iri') + ->booleanNode('use_iri_as_id') + ->defaultTrue() ->end() ->end() ->end() diff --git a/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php b/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php index 1f74cf6bcb..35b53f486b 100644 --- a/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php +++ b/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php @@ -236,7 +236,7 @@ private function runDefaultConfigTests(array $doctrineIntegrationsToLoad = ['orm ], 'enable_phpdoc_parser' => true, 'jsonapi' => [ - 'resource_id_strategy' => 'iri' + 'use_iri_as_id' => true ], ], $config); } From 8fcecc54551c28571b98fa742de3859054fcd873 Mon Sep 17 00:00:00 2001 From: Sebastian Breuers Date: Thu, 29 Jan 2026 14:30:32 +0100 Subject: [PATCH 5/5] feature: deprecate configuration option --- src/Symfony/Bundle/DependencyInjection/Configuration.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Symfony/Bundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/DependencyInjection/Configuration.php index fc5ed6dacd..bbea0cf5c1 100644 --- a/src/Symfony/Bundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/DependencyInjection/Configuration.php @@ -100,9 +100,11 @@ public function getConfigTreeBuilder(): TreeBuilder ->end() ->end() ->arrayNode('jsonapi') + ->setDeprecated('api-platform/core', '5.0') ->addDefaultsIfNotSet() ->children() ->booleanNode('use_iri_as_id') + ->setDeprecated('api-platform/core', '5.0', 'Using IRI in JSON:API output will be no longer supported and replaced by "type" + "id".') ->defaultTrue() ->end() ->end()