Repository navigation
Issue with DTOs on Laravel #8187
Copy link
Copy link
Closed as not planned
Labels
Description
API Platform version(s) affected: 4.2.2
Description
Using the Laravel variant (not tested with Symfony) and nested DTOs for API definition, the documentation is incomplete and defining the output explicitly makes the endpoint fail.
How to reproduce
Create the following files on a new Laravel installation from https://api-platform.com/docs/laravel/
No changes to the default configuration.
// app\State\Nested.php
namespace App\State;
class Nested
{
public function __construct(
public int $id,
) {
}
}
// app\State\TestResponse.php
namespace App\State;
class TestResponse
{
/** @var Nested[] */
public array $data = [];
}
// app\State\TestProvider.php
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
final class TestProvider implements ProviderInterface
{
public function provide(Operation $operation, array $uriVariables = [], array $context = []): ?TestResponse
{
$ref = $uriVariables['ref'];
// Other logic
$response = new TestResponse();
$test = new Nested(1);
$response->data[] = $test;
return $response;
}
}
// app\Models\TestResource.php
namespace App\Models;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use App\State\TestProvider;
use App\State\TestResponse;
#[ApiResource(
operations: [
new Get(
uriTemplate: '/test/{ref}',
provider: TestProvider::class,
//output: TestResponse::class,
),
],
)]
class TestResource
{
}
Register the provider on app\Providers\AppServiceProvider.php
public function boot(): void
{
...
$this->app->tag(TestProvider::class, ProviderInterface::class);
}
If the output section on TestResource is commented out, the following output can be correctly obtained but the documentation is incomplete:
{
"@context": {
"@vocab": "http://localhost/my-api-platform-laravel-app/public/api/docs.jsonld#",
"hydra": "http://www.w3.org/ns/hydra/core#",
"data": "TestResponse/data"
},
"@type": "TestResponse",
"@id": "/my-api-platform-laravel-app/public/api/.well-known/genid/bac7098647960bfe2bc6",
"data": {
"@context": "/my-api-platform-laravel-app/public/api/contexts/TestResource",
"@id": "/my-api-platform-laravel-app/public/api/test/2",
"@type": "Collection",
"totalItems": 1,
"member": [
{
"@type": "Nested",
"@id": "/my-api-platform-laravel-app/public/api/.well-known/genid/5e27cd4ab65753f0b781",
"id": 1
}
]
}
If the output section on TestResource is enabled, the documentation is partially correct (the nested data is not detected) and the endpoint fails with error:
A class metadata factory must be provided in the constructor when setting \"allow_extra_attributes\" to false.
from
{
"@context": "/my-api-platform-laravel-app/public/api/contexts/Error",
"@id": "/my-api-platform-laravel-app/public/api/errors/500",
"@type": "Error",
"trace": [
{
"file": "<path>/vendor/symfony/serializer/Normalizer/AbstractObjectNormalizer.php",
"line": 1099,
"function": "getAllowedAttributes",
"class": "Symfony/Component/Serializer/Normalizer/AbstractNormalizer",
"type": "->"
},
{
"file": "<path>/vendor/symfony/serializer/Normalizer/AbstractObjectNormalizer.php",
"line": 261,
"function": "getAllowedAttributes",
"class": "Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer",
"type": "->"
},
{
"file": "<path>/vendor/symfony/serializer/Normalizer/AbstractObjectNormalizer.php",
"line": 174,
"function": "getAttributes",
"class": "Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer",
"type": "->"
},
{
"file": "<path>/vendor/api-platform/jsonld/Serializer/ObjectNormalizer.php",
"line": 73,
"function": "normalize",
"class": "Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer",
"type": "->"
},
{
"file": "<path>/vendor/symfony/serializer/Serializer.php",
"line": 152,
"function": "normalize",
"class": "ApiPlatform/JsonLd/Serializer/ObjectNormalizer",
"type": "->"
},
{
"file": "<path>/vendor/api-platform/serializer/AbstractCollectionNormalizer.php",
"line": 110,
"function": "normalize",
"class": "Symfony/Component/Serializer/Serializer",
"type": "->"
},
{
"file": "<path>/vendor/api-platform/serializer/AbstractCollectionNormalizer.php",
"line": 81,
"function": "normalizeRawCollection",
"class": "ApiPlatform/Serializer/AbstractCollectionNormalizer",
"type": "->"
},
{
"file": "<path>/vendor/api-platform/hydra/Serializer/CollectionFiltersNormalizer.php",
"line": 80,
"function": "normalize",
"class": "ApiPlatform/Serializer/AbstractCollectionNormalizer",
"type": "->"
},
{
"file": "<path>/vendor/api-platform/hydra/Serializer/PartialCollectionViewNormalizer.php",
"line": 55,
"function": "normalize",
"class": "ApiPlatform/Hydra/Serializer/CollectionFiltersNormalizer",
"type": "->"
},
{
"file": "<path>/vendor/symfony/serializer/Serializer.php",
"line": 152,
"function": "normalize",
"class": "ApiPlatform/Hydra/Serializer/PartialCollectionViewNormalizer",
"type": "->"
},
{
"file": "<path>/vendor/api-platform/serializer/AbstractItemNormalizer.php",
"line": 988,
"function": "normalize",
"class": "Symfony/Component/Serializer/Serializer",
"type": "->"
},
{
"file": "<path>/vendor/symfony/serializer/Normalizer/AbstractObjectNormalizer.php",
"line": 198,
"function": "getAttributeValue",
"class": "ApiPlatform/Serializer/AbstractItemNormalizer",
"type": "->"
},
{
"file": "<path>/vendor/api-platform/serializer/AbstractItemNormalizer.php",
"line": 169,
"function": "normalize",
"class": "Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer",
"type": "->"
},
{
"file": "<path>/vendor/api-platform/jsonld/Serializer/ItemNormalizer.php",
"line": 141,
"function": "normalize",
"class": "ApiPlatform/Serializer/AbstractItemNormalizer",
"type": "->"
},
{
"file": "<path>/vendor/symfony/serializer/Serializer.php",
"line": 152,
"function": "normalize",
"class": "ApiPlatform/JsonLd/Serializer/ItemNormalizer",
"type": "->"
},
{
"file": "<path>/vendor/api-platform/serializer/AbstractItemNormalizer.php",
"line": 133,
"function": "normalize",
"class": "Symfony/Component/Serializer/Serializer",
"type": "->"
},
{
"file": "<path>/vendor/api-platform/jsonld/Serializer/ItemNormalizer.php",
"line": 104,
"function": "normalize",
"class": "ApiPlatform/Serializer/AbstractItemNormalizer",
"type": "->"
},
{
"file": "<path>/vendor/symfony/serializer/Serializer.php",
"line": 152,
"function": "normalize",
"class": "ApiPlatform/JsonLd/Serializer/ItemNormalizer",
"type": "->"
},
{
"file": "<path>/vendor/symfony/serializer/Serializer.php",
"line": 131,
"function": "normalize",
"class": "Symfony/Component/Serializer/Serializer",
"type": "->"
},
{
"file": "<path>/vendor/api-platform/state/Processor/SerializeProcessor.php",
"line": 85,
"function": "serialize",
"class": "Symfony/Component/Serializer/Serializer",
"type": "->"
},
{
"file": "<path>/vendor/api-platform/state/Processor/WriteProcessor.php",
"line": 54,
"function": "process",
"class": "ApiPlatform/State/Processor/SerializeProcessor",
"type": "->"
},
{
"file": "<path>/vendor/api-platform/hydra/State/HydraLinkProcessor.php",
"line": 58,
"function": "process",
"class": "ApiPlatform/State/Processor/WriteProcessor",
"type": "->"
},
{
"file": "<path>/vendor/api-platform/laravel/Controller/ApiPlatformController.php",
"line": 104,
"function": "process",
"class": "ApiPlatform/Hydra/State/HydraLinkProcessor",
"type": "->"
},
{
"file": "<path>/vendor/laravel/framework/src/Illuminate/Routing/Controller.php",
"line": 54,
"function": "__invoke",
"class": "ApiPlatform/Laravel/Controller/ApiPlatformController",
"type": "->"
},
{
"file": "<path>/vendor/laravel/framework/src/Illuminate/Routing/ControllerDispatcher.php",
"line": 43,
"function": "callAction",
"class": "Illuminate/Routing/Controller",
"type": "->"
},
{
"file": "<path>/vendor/laravel/framework/src/Illuminate/Routing/Route.php",
"line": 265,
"function": "dispatch",
"class": "Illuminate/Routing/ControllerDispatcher",
"type": "->"
},
{
"file": "<path>/vendor/laravel/framework/src/Illuminate/Routing/Route.php",
"line": 211,
"function": "runController",
"class": "Illuminate/Routing/Route",
"type": "->"
},
{
"file": "<path>/vendor/laravel/framework/src/Illuminate/Routing/Router.php",
"line": 822,
"function": "run",
"class": "Illuminate/Routing/Route",
"type": "->"
},
{
"file": "<path>/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php",
"line": 180,
"function": "Illuminate/Routing/{closure}",
"class": "Illuminate/Routing/Router",
"type": "->"
},
{
"file": "<path>/vendor/api-platform/laravel/ApiPlatformMiddleware.php",
"line": 47,
"function": "Illuminate/Pipeline/{closure}",
"class": "Illuminate/Pipeline/Pipeline",
"type": "->"
},
{
"file": "<path>/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php",
"line": 219,
"function": "handle",
"class": "ApiPlatform/Laravel/ApiPlatformMiddleware",
"type": "->"
},
{
"file": "<path>/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php",
"line": 137,
"function": "Illuminate/Pipeline/{closure}",
"class": "Illuminate/Pipeline/Pipeline",
"type": "->"
},
{
"file": "<path>/vendor/laravel/framework/src/Illuminate/Routing/Router.php",
"line": 821,
"function": "then",
"class": "Illuminate/Pipeline/Pipeline",
"type": "->"
},
{
"file": "<path>/vendor/laravel/framework/src/Illuminate/Routing/Router.php",
"line": 800,
"function": "runRouteWithinStack",
"class": "Illuminate/Routing/Router",
"type": "->"
},
{
"file": "<path>/vendor/laravel/framework/src/Illuminate/Routing/Router.php",
"line": 764,
"function": "runRoute",
"class": "Illuminate/Routing/Router",
"type": "->"
},
{
"file": "<path>/vendor/laravel/framework/src/Illuminate/Routing/Router.php",
"line": 753,
"function": "dispatchToRoute",
"class": "Illuminate/Routing/Router",
"type": "->"
},
{
"file": "<path>/vendor/laravel/framework/src/Illuminate/Foundation/Http/Kernel.php",
"line": 200,
"function": "dispatch",
"class": "Illuminate/Routing/Router",
"type": "->"
},
{
"file": "<path>/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php",
"line": 180,
"function": "Illuminate/Foundation/Http/{closure}",
"class": "Illuminate/Foundation/Http/Kernel",
"type": "->"
},
{
"file": "<path>/vendor/laravel/framework/src/Illuminate/Foundation/Http/Middleware/TransformsRequest.php",
"line": 21,
"function": "Illuminate/Pipeline/{closure}",
"class": "Illuminate/Pipeline/Pipeline",
"type": "->"
},
{
"file": "<path>/vendor/laravel/framework/src/Illuminate/Foundation/Http/Middleware/ConvertEmptyStringsToNull.php",
"line": 31,
"function": "handle",
"class": "Illuminate/Foundation/Http/Middleware/TransformsRequest",
"type": "->"
},
{
"file": "<path>/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php",
"line": 219,
"function": "handle",
"class": "Illuminate/Foundation/Http/Middleware/ConvertEmptyStringsToNull",
"type": "->"
},
{
"file": "<path>/vendor/laravel/framework/src/Illuminate/Foundation/Http/Middleware/TransformsRequest.php",
"line": 21,
"function": "Illuminate/Pipeline/{closure}",
"class": "Illuminate/Pipeline/Pipeline",
"type": "->"
},
{
"file": "<path>/vendor/laravel/framework/src/Illuminate/Foundation/Http/Middleware/TrimStrings.php",
"line": 51,
"function": "handle",
"class": "Illuminate/Foundation/Http/Middleware/TransformsRequest",
"type": "->"
},
{
"file": "<path>/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php",
"line": 219,
"function": "handle",
"class": "Illuminate/Foundation/Http/Middleware/TrimStrings",
"type": "->"
},
{
"file": "<path>/vendor/laravel/framework/src/Illuminate/Http/Middleware/ValidatePostSize.php",
"line": 27,
"function": "Illuminate/Pipeline/{closure}",
"class": "Illuminate/Pipeline/Pipeline",
"type": "->"
},
{
"file": "<path>/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php",
"line": 219,
"function": "handle",
"class": "Illuminate/Http/Middleware/ValidatePostSize",
"type": "->"
},
{
"file": "<path>/vendor/laravel/framework/src/Illuminate/Foundation/Http/Middleware/PreventRequestsDuringMaintenance.php",
"line": 109,
"function": "Illuminate/Pipeline/{closure}",
"class": "Illuminate/Pipeline/Pipeline",
"type": "->"
},
{
"file": "<path>/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php",
"line": 219,
"function": "handle",
"class": "Illuminate/Foundation/Http/Middleware/PreventRequestsDuringMaintenance",
"type": "->"
},
{
"file": "<path>/vendor/laravel/framework/src/Illuminate/Http/Middleware/HandleCors.php",
"line": 61,
"function": "Illuminate/Pipeline/{closure}",
"class": "Illuminate/Pipeline/Pipeline",
"type": "->"
},
{
"file": "<path>/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php",
"line": 219,
"function": "handle",
"class": "Illuminate/Http/Middleware/HandleCors",
"type": "->"
},
{
"file": "<path>/vendor/laravel/framework/src/Illuminate/Http/Middleware/TrustProxies.php",
"line": 58,
"function": "Illuminate/Pipeline/{closure}",
"class": "Illuminate/Pipeline/Pipeline",
"type": "->"
},
{
"file": "<path>/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php",
"line": 219,
"function": "handle",
"class": "Illuminate/Http/Middleware/TrustProxies",
"type": "->"
},
{
"file": "<path>/vendor/laravel/framework/src/Illuminate/Foundation/Http/Middleware/InvokeDeferredCallbacks.php",
"line": 22,
"function": "Illuminate/Pipeline/{closure}",
"class": "Illuminate/Pipeline/Pipeline",
"type": "->"
},
{
"file": "<path>/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php",
"line": 219,
"function": "handle",
"class": "Illuminate/Foundation/Http/Middleware/InvokeDeferredCallbacks",
"type": "->"
},
{
"file": "<path>/vendor/laravel/framework/src/Illuminate/Http/Middleware/ValidatePathEncoding.php",
"line": 26,
"function": "Illuminate/Pipeline/{closure}",
"class": "Illuminate/Pipeline/Pipeline",
"type": "->"
},
{
"file": "<path>/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php",
"line": 219,
"function": "handle",
"class": "Illuminate/Http/Middleware/ValidatePathEncoding",
"type": "->"
},
{
"file": "<path>/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php",
"line": 137,
"function": "Illuminate/Pipeline/{closure}",
"class": "Illuminate/Pipeline/Pipeline",
"type": "->"
},
{
"file": "<path>/vendor/laravel/framework/src/Illuminate/Foundation/Http/Kernel.php",
"line": 175,
"function": "then",
"class": "Illuminate/Pipeline/Pipeline",
"type": "->"
},
{
"file": "<path>/vendor/laravel/framework/src/Illuminate/Foundation/Http/Kernel.php",
"line": 144,
"function": "sendRequestThroughRouter",
"class": "Illuminate/Foundation/Http/Kernel",
"type": "->"
},
{
"file": "<path>/vendor/laravel/framework/src/Illuminate/Foundation/Application.php",
"line": 1220,
"function": "handle",
"class": "Illuminate/Foundation/Http/Kernel",
"type": "->"
},
{
"file": "<path>/public/index.php",
"line": 20,
"function": "handleRequest",
"class": "Illuminate/Foundation/Application",
"type": "->"
}
],
"type": "/errors/500",
"title": "An error occurred",
"status": 500,
"detail": "A class metadata factory must be provided in the constructor when setting \"allow_extra_attributes\" to false."
}
Reactions are currently unavailable
Activity
soyuka commentedon May 21, 2026
Two things:
1. The 500 error is fixed.
Laravel's
ApiPlatformProviderconstructed Symfony'sObjectNormalizerwithoutClassMetadataFactoryInterface. Whenoutput:is set, the output DTO is normalized by Symfony'sObjectNormalizer(it is not an#[ApiResource]), and any context relying on class metadata triggersLogicException: A class metadata factory must be provided....Fixed in 4fa6d3fa2, released in v4.2.10. You're on 4.2.2 — upgrade to the latest 4.2.x and the 500 goes away.
2. The documentation incompleteness is by design.
Per General Design Considerations:
And Using DTOs:
API Platform's metadata pipeline (Hydra docs, OpenAPI, JSON Schema, property security, etc.) walks
#[ApiResource]classes only.TestResponseandNestedare plain POPOs → invisible toResourceMetadataCollectionFactoryand the doc generators. Same architectural limitation as #8087.Intentional: pulling arbitrary referenced classes into the resource-metadata pipeline would duplicate Symfony's PropertyInfo/Serializer mapping and balloon overhead for every embedded object.
Recommended approach —
TestResponseis the public shape, mark it as the resource and drop theTestResourcewrapper. For nested DTOs you don't want to expose as endpoints, use#[NotExposed]:Full Hydra/OpenAPI documentation, no public endpoints for
Nested, IRI generation wired if you later need it.Closing — runtime bug fixed in 4.2.10+, doc behavior matches the documented design contract. Reopen with a concrete proposal if you have one that doesn't pull non-resource classes into the metadata pipeline.
Dasc3er commentedon May 30, 2026
Thanks!