Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
71918e4
fix(jsonschema): respect readableLink for resource-typed properties o…
wuchen90 Jun 29, 2026
6682985
fix(doctrine): support filtering scalar enum columns by IRI (#8358)
soyuka Jun 29, 2026
f1f33ca
fix(ci): link monorepo root in laravel e2e so siblings resolve from w…
soyuka Jun 29, 2026
43e3b4c
fix(openapi): don't apply the global name converter to the generated …
soyuka Jun 30, 2026
fe84af2
fix(symfony): allow null $data in PlaceholderAction (#8359)
soyuka Jun 30, 2026
a9c1b53
fix: clone Parameters before mutation in security and validator provi…
ostrolucky Jul 2, 2026
f9d3706
fix(mcp): make tools/list resilient to an empty registry (#8371)
soyuka Jul 3, 2026
48e8f97
fix(doctrine): use PHP property name in DQL for modern filters with n…
soyuka Jul 3, 2026
798b4cc
fix(serializer): denormalize nullable collections of enums/objects (#…
soyuka Jul 3, 2026
340e982
fix(test): remove stale phpstan ignores in CollectionNormalizerTest (…
soyuka Jul 3, 2026
328cb67
fix(symfony): don't expose entrypoint in openapi format (#8383)
soyuka Jul 3, 2026
95dd023
doc: changelog 4.3.16
soyuka Jul 3, 2026
56de597
fix(doctrine): fetch_data=false reference for stateOptions resources …
bendavies Jul 8, 2026
079c746
fix(openapi): don't inherit name converter from a possibly-missing Sy…
soyuka Jul 8, 2026
b396ff9
fix(serializer): report enum backing type in denormalization violatio…
soyuka Jul 11, 2026
3d48a4a
fix(serializer): preserve denormalization errors for nullable object …
bendavies Jul 11, 2026
815f7f4
doc: changelog 4.3.17
soyuka Jul 12, 2026
400463f
chore(graphql): remove stray committed foo.diff patch file (#8395)
soyuka Jul 12, 2026
083dabe
Merge 4.3 into 4.4
soyuka Jul 12, 2026
722ed09
fix(validator): forward-port enum backing type into DenormalizationVi…
soyuka Jul 12, 2026
1cacc0b
fix(test): drop stale serializer 8.1 deprecation guard on ported enum…
soyuka Jul 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1193,7 +1193,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
Expand Down
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,31 @@
* [c9e5071d9](https://github.com/api-platform/core/commit/c9e5071d973caedeb9b554f885dedbc89743b93d) feat(symfony): deprecate Symfony Security AccessDeniedException (#8318)
* [cc0ae1254](https://github.com/api-platform/core/commit/cc0ae1254acaf9742c7f899dd24a14d46c89ca6e) feat: support dynamic HTTP response status code via request attribute (#7904)

## 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

* [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
Expand Down
22 changes: 22 additions & 0 deletions src/Doctrine/Odm/Filter/IriFilter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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);
}
}
2 changes: 1 addition & 1 deletion src/Doctrine/Odm/NestedPropertyHelperTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'] ?? [];
Expand Down
54 changes: 52 additions & 2 deletions src/Doctrine/Orm/Filter/IriFilter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
*/
Expand All @@ -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);
Expand All @@ -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'];
Expand All @@ -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);
}
}
2 changes: 1 addition & 1 deletion src/Doctrine/Orm/NestedPropertyHelperTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'] ?? [];
Expand Down
6 changes: 4 additions & 2 deletions src/Doctrine/Orm/State/ItemProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,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;
}

Expand Down
31 changes: 31 additions & 0 deletions src/Doctrine/Orm/Tests/Fixtures/Model/EmployeeApi.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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;
}
79 changes: 79 additions & 0 deletions src/Doctrine/Orm/Tests/State/ItemProviderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
Loading
Loading