Skip to content

feat(object-mapper): Honor the output class in ObjectMapperOutputProcessor on write operations - #8420

Closed
dylan-rumble wants to merge 1 commit into
api-platform:4.3from
dylan-rumble:feat/object-mapper-support-custom-output-dto
Closed

feat(object-mapper): Honor the output class in ObjectMapperOutputProcessor on write operations#8420
dylan-rumble wants to merge 1 commit into
api-platform:4.3from
dylan-rumble:feat/object-mapper-support-custom-output-dto

Conversation

@dylan-rumble

Copy link
Copy Markdown
Q A
Branch? 4.3
License MIT
Doc PR api-platform/docs#...

Problem

When using the Symfony ObjectMapper integration ( stateOptions: new Options(entityClass: MyEntity::class) ), the read side and write side are inconsistent in how they resolve the target class.

On the read side,  ObjectMapperProvider correctly respects a configured  output  class, falling back to the operation class:

// src/State/Provider/ObjectMapperProvider.php
$data = $this->objectMapper->map($data, $operation->getOutput()['class'] ?? $operation->getClass());

On the write side,  ObjectMapperOutputProcessor ignores the output class entirely and always maps back to $operation->getClass() :

// src/State/Processor/ObjectMapperOutputProcessor.php
$dto = $this->objectMapper->map($data, $operation->getClass());

As a result, a write operation (POST / PATCH / PUT) that declares a dedicated output class silently maps the persisted entity back to the resource class instead of the requested output DTO. There is no way to return a different representation from a write than from a read when using ObjectMapper.

This is particularly limiting when the resource class itself cannot serve as a valid output representation for writes (for example, when the resource's IRI cannot be generated from the write result), forcing users to fall back to output: false and losing the response body altogether.

Fix

Mirror the read side so the processor honors the operation's output class when one is defined, falling back to the operation class otherwise:

$dto = $this->objectMapper->map($data, $operation->getOutput()['class'] ?? $operation->getClass());

This makes read and write paths symmetric and lets a write operation return a dedicated output DTO via ObjectMapper.

Backward compatibility

Fully backward compatible: when no output class is configured, getOutput() is null and behavior is unchanged (maps to $operation->getClass()).

@dylan-rumble

dylan-rumble commented Aug 18, 2026

Copy link
Copy Markdown
Author

@soyuka Can you please take a look at this PR

@dylan-rumble
dylan-rumble force-pushed the feat/object-mapper-support-custom-output-dto branch 2 times, most recently from edaaefd to 4751d53 Compare August 20, 2026 08:46
@dylan-rumble
dylan-rumble force-pushed the feat/object-mapper-support-custom-output-dto branch from 4751d53 to 0cac163 Compare September 2, 2026 07:20
@soyuka

soyuka commented Sep 2, 2026

Copy link
Copy Markdown
Member

Thanks for the PR and for the clear write-up. I'm going to close it, but not because the diff is wrong in isolation: this exact change was already proposed in #7611 and requested again in #7940, and the answer is the same. Let me restate it here so the reasoning is easy to find.

Why ObjectMapperOutputProcessor maps to getClass() on purpose

  • The processor's job is to turn the persisted entity back into the API resource. The rest of the write chain relies on that: Location/Content-Location on a 201 are derived from a resource instance. With a non-resource output DTO they would silently degrade to the collection IRI.
  • output: is a serialization-side contract. The serializer takes the output path when the object it receives is an instance of the output class. It never converts a resource into that class, and ObjectMapper is not the place to do it: it would require users to declare #[Map] between the entity and every custom output, which is not the mapping most people have.
  • The provider (read side) mapping to output['class'] since fix(metadata): use operation output class for mapping instead of operation class #7601 is what makes the collection use case in the docs work. We tried extending that logic to the write side with input classes in fix(state): prioritize input class over output in ObjectMapperProvider #7879 and had to revert it in fix(state): do not map to input class in ObjectMapperProvider #7892 because it broke PATCH semantics. We'd rather keep the write side simple and predictable.

What to do instead

With the ObjectMapper integration the #[ApiResource] class is the DTO. If a write must return a different representation, give that representation its own resource class, sharing the entity through stateOptions, and use input: for the request body:

#[ApiResource(
    shortName: 'Book',
    stateOptions: new Options(entityClass: BookEntity::class),
    operations: [new Get(), new GetCollection(), new Patch(input: UpdateBook::class)],
)]
#[Map(target: BookEntity::class)]
final class Book { /* full representation */ }

#[ApiResource(
    shortName: 'Book',
    stateOptions: new Options(entityClass: BookEntity::class),
    operations: [
        new Post(
            uriTemplate: '/books',
            input: CreateBook::class,
            itemUriTemplate: '/books/{id}', // reuse the Book item IRI for @id
        ),
    ],
)]
#[Map(source: BookEntity::class)]
final class BookCreated { /* what POST returns */ }

The request is deserialized into CreateBook, mapped onto the entity, persisted, then mapped back to BookCreated, which is a real resource, so IRIs, JSON-LD context and headers all work.

If the response is not a projection of the entity at all, use a custom processor with an input: DTO, as described in the "Custom Business Logic" section of https://api-platform.com/docs/core/dto/, and return whatever object you want to serialize.

We'll update the documentation to state explicitly that output: combined with the ObjectMapper applies to read operations, and to show the pattern above. Thanks again!

@soyuka

soyuka commented Sep 2, 2026

Copy link
Copy Markdown
Member

Documentation follow-up: api-platform/docs#2321 (clarifies that output with the Object Mapper applies to read operations and documents the dedicated-resource pattern for writes).

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants