From 845d64bfa42ecbb4a56cc3c815141e1139213507 Mon Sep 17 00:00:00 2001 From: soyuka Date: Sun, 29 Mar 2026 15:54:20 +0200 Subject: [PATCH] dto review --- core/dto.md | 118 ++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 101 insertions(+), 17 deletions(-) diff --git a/core/dto.md b/core/dto.md index bf8c36067aa..84ce7739f71 100644 --- a/core/dto.md +++ b/core/dto.md @@ -123,8 +123,9 @@ final class Book ### Implementation Details: The Object Mapper Magic -Automated mapping relies on two internal classes: `ApiPlatform\State\Provider\ObjectMapperProvider` -and `ApiPlatform\State\Processor\ObjectMapperProcessor`. +Automated mapping relies on three internal classes: `ApiPlatform\State\Provider\ObjectMapperProvider`, +`ApiPlatform\State\Processor\ObjectMapperInputProcessor`, and +`ApiPlatform\State\Processor\ObjectMapperOutputProcessor`. These classes act as decorators around the standard Provider/Processor chain. They are activated when: @@ -133,20 +134,75 @@ when: - `stateOptions` are configured with an `entityClass` (or `documentClass` for ODM). - The Resource (and Entity for writes) classes have the `#[Map]` attribute. -#### How it works internally - -**Read (GET):** +#### Read flow (GET) + +```mermaid +sequenceDiagram + participant Client + participant Provider as Doctrine Provider + participant OMP as ObjectMapperProvider + participant Serializer + + Client->>Provider: GET /books/1 + Provider->>Provider: Fetch Entity from DB + Provider->>OMP: Entity + OMP->>OMP: map(Entity, output ?? ResourceClass) + OMP->>Serializer: Resource DTO + Serializer->>Client: JSON response +``` The `ObjectMapperProvider` delegates fetching the data to the underlying Doctrine provider (which -returns an Entity). It then uses `$objectMapper->map($entity, $resourceClass)` to transform the -Entity into your DTO Resource. +returns an Entity). It then maps the Entity to the **output class** (if `output:` is configured on +the operation) or the **resource class**, using `$objectMapper->map($entity, $outputOrResourceClass)`. +The `input:` configuration is not used during read operations. + +#### Write flow (POST/PUT/PATCH) + +```mermaid +sequenceDiagram + participant Client + participant Serializer as Deserializer + participant OMIP as ObjectMapper
InputProcessor + participant VP as ValidateProcessor + participant WP as WriteProcessor + participant OMOP as ObjectMapper
OutputProcessor + participant Ser as Serializer + + Client->>Serializer: POST /books (JSON body) + Serializer->>Serializer: Deserialize → input DTO (or Resource) + Serializer->>OMIP: Input DTO + OMIP->>OMIP: map(DTO, Entity) + OMIP->>VP: Entity + VP->>VP: Validate Entity + VP->>WP: Entity + WP->>WP: Persist (Doctrine flush) + WP->>OMOP: Persisted Entity + OMOP->>OMOP: map(Entity, ResourceClass) + OMOP->>Ser: Resource DTO + Ser->>Client: JSON response +``` + +The serializer deserializes the request body into the **input class** (if `input:` is configured) +or the resource class. The `ObjectMapperInputProcessor` then receives that deserialized object and +maps it to the Entity. For PATCH, it maps onto the existing Entity retrieved by the provider +(stored in `request->attributes['mapped_data']`), so only the properties set by the client are +applied. It then delegates to the underlying Doctrine processor to persist the Entity. After +persistence, `ObjectMapperOutputProcessor` maps the persisted Entity back to the **resource +class**. + +#### Without stateOptions (custom or static provider) + +`stateOptions` is not required to use the Object Mapper. When it is absent, the three decorator +classes still activate as long as the resource class (and input class for writes) carry the +`#[Map]` attribute. The difference is in what the mapper targets: -**Write (POST/PUT/PATCH):** +- **`ObjectMapperProvider`** maps your provider's return value to the **output class** (if set) or + the **resource class** — determined by `getOutput()['class'] ?? getClass()`. +- **`ObjectMapperInputProcessor`** maps the deserialized input to the **resource class** — it falls + back to `$operation->getClass()` when no entity class is found in `stateOptions`. -The `ObjectMapperProcessor` receives the deserialized Input DTO. It uses -`$objectMapper->map($inputDto, $entityClass)` to transform the input into an Entity instance. It -then delegates to the underlying Doctrine processor (to persist the Entity). Finally, it maps the -persisted Entity back to the Output DTO Resource. +This is useful for non-Doctrine backends (static data, remote APIs, in-memory stores) where you +still want clean DTO separation without writing manual mapping code in a custom processor. ## 2. Automated Mapped Inputs and Outputs @@ -188,8 +244,10 @@ final class CreateBook ### UpdateBook DTO +For PATCH, properties must be **uninitialized** (no default values). A default value causes every +PATCH request to overwrite that field even when the client did not include it in the request body. + ```php - [!NOTE] +> `input:` and `output:` operate at the **serializer** layer: `input:` is the class the request +> body is deserialized into; `output:` is the class the serializer normalizes into the response. +> The ObjectMapper (`map: true`, `#[Map]`) operates at the **state pipeline** layer: the Provider +> maps the Entity to the output class (or resource class), and the InputProcessor maps the input +> DTO to the Entity. These two mechanisms are independent and can be combined safely. +> +> When `stateOptions` is configured, the ObjectMapper maps between your DTO and the Doctrine +> Entity. Without `stateOptions`, the ObjectMapper still works but maps to the resource class +> itself — useful when you bring your own provider. ```php // src/Api/Resource/Book.php @@ -243,15 +312,15 @@ In your Book resource, configure the operations to use these classes via input a stateOptions: new Options(entityClass: BookEntity::class), operations: [ new Get(), - // Use the specialized Output DTO for collections + // ObjectMapperProvider maps Entity -> BookCollection for this operation new GetCollection( output: BookCollection::class ), - // Use the specialized Input DTO for creation + // Serializer deserializes request body into CreateBook; ObjectMapperInputProcessor maps CreateBook -> Entity new Post( input: CreateBook::class ), - // Use the specialized Input DTO for updates + // Serializer deserializes request body into UpdateBook; ObjectMapperInputProcessor maps UpdateBook -> existing Entity new Patch( input: UpdateBook::class ), @@ -260,6 +329,21 @@ In your Book resource, configure the operations to use these classes via input a final class Book { /* ... */ } ``` +### Recommendations on input and output + +**Declare your operations on the Resource class** — it represents the JSON contract of your API. +Avoid using `output:` on write operations (`Post`, `Put`, `Patch`, `Delete`). The +`ObjectMapperOutputProcessor` already maps the persisted Entity back to the Resource class +automatically. Adding an explicit `output:` on writes creates confusion and can lead to subtle +bugs (see [#7745](https://github.com/api-platform/core/issues/7745)). + +The main legitimate use case for `output:` is on `GetCollection` when you need a lighter +representation with fewer fields than the main Resource. Even then, consider whether serialization +groups or a separate Resource class might be clearer. + +Use `input:` freely for write operations — it is the right tool for differentiating Create vs +Update validation and accepted fields. + ## 3. Custom Business Logic (Custom Processor) For complex business actions (like applying a discount), standard CRUD mapping isn't enough. You