Skip to content

Issue with DTOs on Laravel #8187

Closed as not planned
Closed as not planned

Description

@Dasc3er

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
      }
    ]
  }
Image

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:

Image
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."
}

Activity

  1. soyuka commented on May 21, 2026

    @soyuka
    Member

    Two things:

    1. The 500 error is fixed.

    Laravel's ApiPlatformProvider constructed Symfony's ObjectNormalizer without ClassMetadataFactoryInterface. When output: is set, the output DTO is normalized by Symfony's ObjectNormalizer (it is not an #[ApiResource]), and any context relying on class metadata triggers LogicException: 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:

    you have to write a plain old PHP object (POPO) representing the input and output of your endpoint. This is the class that is marked with the `#[ApiResource]` attribute […] there is a 1-1 mapping between this class and those docs.

    And Using DTOs:

    DTO as a Resource: the class marked with `#[ApiResource]` is the DTO, effectively becoming the "contract" of your API.

    API Platform's metadata pipeline (Hydra docs, OpenAPI, JSON Schema, property security, etc.) walks #[ApiResource] classes only. TestResponse and Nested are plain POPOs → invisible to ResourceMetadataCollectionFactory and 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 — TestResponse is the public shape, mark it as the resource and drop the TestResource wrapper. For nested DTOs you don't want to expose as endpoints, use #[NotExposed]:

    use ApiPlatform\Metadata\ApiResource;
    use ApiPlatform\Metadata\Get;
    use ApiPlatform\Metadata\NotExposed;
    
    #[ApiResource(
        operations: [
            new Get(uriTemplate: '/test/{ref}', provider: TestProvider::class),
        ],
    )]
    class TestResponse
    {
        /** @var Nested[] */
        public array $data = [];
    }
    
    #[NotExposed]
    class Nested
    {
        public function __construct(public int $id) {}
    }

    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.

  2. added theissue type on May 29, 2026
  3. Dasc3er commented on May 30, 2026

    @Dasc3er
    Author

    Thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions