@@ -123,8 +123,9 @@ final class Book
123123
124124### Implementation Details: The Object Mapper Magic
125125
126- Automated mapping relies on two internal classes: ` ApiPlatform\State\Provider\ObjectMapperProvider `
127- and ` ApiPlatform\State\Processor\ObjectMapperProcessor ` .
126+ Automated mapping relies on three internal classes: ` ApiPlatform\State\Provider\ObjectMapperProvider ` ,
127+ ` ApiPlatform\State\Processor\ObjectMapperInputProcessor ` , and
128+ ` ApiPlatform\State\Processor\ObjectMapperOutputProcessor ` .
128129
129130These classes act as decorators around the standard Provider/Processor chain. They are activated
130131when:
@@ -133,20 +134,75 @@ when:
133134- ` stateOptions ` are configured with an ` entityClass ` (or ` documentClass ` for ODM).
134135- The Resource (and Entity for writes) classes have the ` #[Map] ` attribute.
135136
136- #### How it works internally
137-
138- ** Read (GET):**
137+ #### Read flow (GET)
138+
139+ ``` mermaid
140+ sequenceDiagram
141+ participant Client
142+ participant Provider as Doctrine Provider
143+ participant OMP as ObjectMapperProvider
144+ participant Serializer
145+
146+ Client->>Provider: GET /books/1
147+ Provider->>Provider: Fetch Entity from DB
148+ Provider->>OMP: Entity
149+ OMP->>OMP: map(Entity, output ?? ResourceClass)
150+ OMP->>Serializer: Resource DTO
151+ Serializer->>Client: JSON response
152+ ```
139153
140154The ` ObjectMapperProvider ` delegates fetching the data to the underlying Doctrine provider (which
141- returns an Entity). It then uses ` $objectMapper->map($entity, $resourceClass) ` to transform the
142- Entity into your DTO Resource.
155+ returns an Entity). It then maps the Entity to the ** output class** (if ` output: ` is configured on
156+ the operation) or the ** resource class** , using ` $objectMapper->map($entity, $outputOrResourceClass) ` .
157+ The ` input: ` configuration is not used during read operations.
158+
159+ #### Write flow (POST/PUT/PATCH)
160+
161+ ``` mermaid
162+ sequenceDiagram
163+ participant Client
164+ participant Serializer as Deserializer
165+ participant OMIP as ObjectMapper<br/>InputProcessor
166+ participant VP as ValidateProcessor
167+ participant WP as WriteProcessor
168+ participant OMOP as ObjectMapper<br/>OutputProcessor
169+ participant Ser as Serializer
170+
171+ Client->>Serializer: POST /books (JSON body)
172+ Serializer->>Serializer: Deserialize → input DTO (or Resource)
173+ Serializer->>OMIP: Input DTO
174+ OMIP->>OMIP: map(DTO, Entity)
175+ OMIP->>VP: Entity
176+ VP->>VP: Validate Entity
177+ VP->>WP: Entity
178+ WP->>WP: Persist (Doctrine flush)
179+ WP->>OMOP: Persisted Entity
180+ OMOP->>OMOP: map(Entity, ResourceClass)
181+ OMOP->>Ser: Resource DTO
182+ Ser->>Client: JSON response
183+ ```
184+
185+ The serializer deserializes the request body into the ** input class** (if ` input: ` is configured)
186+ or the resource class. The ` ObjectMapperInputProcessor ` then receives that deserialized object and
187+ maps it to the Entity. For PATCH, it maps onto the existing Entity retrieved by the provider
188+ (stored in ` request->attributes['mapped_data'] ` ), so only the properties set by the client are
189+ applied. It then delegates to the underlying Doctrine processor to persist the Entity. After
190+ persistence, ` ObjectMapperOutputProcessor ` maps the persisted Entity back to the ** resource
191+ class** .
192+
193+ #### Without stateOptions (custom or static provider)
194+
195+ ` stateOptions ` is not required to use the Object Mapper. When it is absent, the three decorator
196+ classes still activate as long as the resource class (and input class for writes) carry the
197+ ` #[Map] ` attribute. The difference is in what the mapper targets:
143198
144- ** Write (POST/PUT/PATCH):**
199+ - ** ` ObjectMapperProvider ` ** maps your provider's return value to the ** output class** (if set) or
200+ the ** resource class** — determined by ` getOutput()['class'] ?? getClass() ` .
201+ - ** ` ObjectMapperInputProcessor ` ** maps the deserialized input to the ** resource class** — it falls
202+ back to ` $operation->getClass() ` when no entity class is found in ` stateOptions ` .
145203
146- The ` ObjectMapperProcessor ` receives the deserialized Input DTO. It uses
147- ` $objectMapper->map($inputDto, $entityClass) ` to transform the input into an Entity instance. It
148- then delegates to the underlying Doctrine processor (to persist the Entity). Finally, it maps the
149- persisted Entity back to the Output DTO Resource.
204+ This is useful for non-Doctrine backends (static data, remote APIs, in-memory stores) where you
205+ still want clean DTO separation without writing manual mapping code in a custom processor.
150206
151207## 2. Automated Mapped Inputs and Outputs
152208
@@ -188,8 +244,10 @@ final class CreateBook
188244
189245### UpdateBook DTO
190246
247+ For PATCH, properties must be ** uninitialized** (no default values). A default value causes every
248+ PATCH request to overwrite that field even when the client did not include it in the request body.
249+
191250``` php
192- <?php
193251// src/Api/Dto/UpdateBook.php
194252namespace App\Api\Dto;
195253
@@ -234,7 +292,18 @@ final class BookCollection
234292
235293#### Wiring it all together in the Resource
236294
237- In your Book resource, configure the operations to use these classes via input and output.
295+ In your Book resource, configure the operations to use these classes via ` input ` and ` output ` .
296+
297+ > [ !NOTE]
298+ > ` input: ` and ` output: ` operate at the ** serializer** layer: ` input: ` is the class the request
299+ > body is deserialized into; ` output: ` is the class the serializer normalizes into the response.
300+ > The ObjectMapper (` map: true ` , ` #[Map] ` ) operates at the ** state pipeline** layer: the Provider
301+ > maps the Entity to the output class (or resource class), and the InputProcessor maps the input
302+ > DTO to the Entity. These two mechanisms are independent and can be combined safely.
303+ >
304+ > When ` stateOptions ` is configured, the ObjectMapper maps between your DTO and the Doctrine
305+ > Entity. Without ` stateOptions ` , the ObjectMapper still works but maps to the resource class
306+ > itself — useful when you bring your own provider.
238307
239308``` php
240309// src/Api/Resource/Book.php
@@ -243,15 +312,15 @@ In your Book resource, configure the operations to use these classes via input a
243312 stateOptions: new Options(entityClass: BookEntity::class),
244313 operations: [
245314 new Get(),
246- // Use the specialized Output DTO for collections
315+ // ObjectMapperProvider maps Entity -> BookCollection for this operation
247316 new GetCollection(
248317 output: BookCollection::class
249318 ),
250- // Use the specialized Input DTO for creation
319+ // Serializer deserializes request body into CreateBook; ObjectMapperInputProcessor maps CreateBook -> Entity
251320 new Post(
252321 input: CreateBook::class
253322 ),
254- // Use the specialized Input DTO for updates
323+ // Serializer deserializes request body into UpdateBook; ObjectMapperInputProcessor maps UpdateBook -> existing Entity
255324 new Patch(
256325 input: UpdateBook::class
257326 ),
@@ -260,6 +329,21 @@ In your Book resource, configure the operations to use these classes via input a
260329final class Book { /* ... */ }
261330```
262331
332+ ### Recommendations on input and output
333+
334+ ** Declare your operations on the Resource class** — it represents the JSON contract of your API.
335+ Avoid using ` output: ` on write operations (` Post ` , ` Put ` , ` Patch ` , ` Delete ` ). The
336+ ` ObjectMapperOutputProcessor ` already maps the persisted Entity back to the Resource class
337+ automatically. Adding an explicit ` output: ` on writes creates confusion and can lead to subtle
338+ bugs (see [ #7745 ] ( https://github.com/api-platform/core/issues/7745 ) ).
339+
340+ The main legitimate use case for ` output: ` is on ` GetCollection ` when you need a lighter
341+ representation with fewer fields than the main Resource. Even then, consider whether serialization
342+ groups or a separate Resource class might be clearer.
343+
344+ Use ` input: ` freely for write operations — it is the right tool for differentiating Create vs
345+ Update validation and accepted fields.
346+
263347## 3. Custom Business Logic (Custom Processor)
264348
265349For complex business actions (like applying a discount), standard CRUD mapping isn't enough. You
0 commit comments