Skip to content

fix(state): support output DTOs on write operations with ObjectMapper - #8497

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

fix(state): support output DTOs on write operations with ObjectMapper#8497
dylan-rumble wants to merge 1 commit into
api-platform:4.3from
dylan-rumble:feat/object-mapper-output-dto-write-operation-support

Conversation

@dylan-rumble

@dylan-rumble dylan-rumble commented Sep 2, 2026

Copy link
Copy Markdown
Q A
Branch? 4.3
License MIT
Doc PR to follow (see "Documentation" below)

Follow-up to #8420 (closed), which attempted to fix this with a one-liner in ObjectMapperOutputProcessor. @soyuka's review was right: that diff alone silently degraded IRIs and headers. This PR addresses the whole write path instead, and answers each concern raised there. It comes with functional tests asserting on Location, Content-Location and @id, not just response bodies.

Problem

Declaring output: on a write operation with the ObjectMapper integration (stateOptions: new Options(entityClass: ...)) is currently broken before the output mapping is ever reached — it doesn't just return the wrong representation:

What this PR changes

Four small, gated changes (each new path requires canMap() and an explicit output class, so non-ObjectMapper users and read operations are untouched):

  1. ObjectMapperProvider — on write methods, map the entity to the resource class, not the output class. The provided data is the deserialization target: it must be an instance of the resource class for object_to_populate to work. This fixes the PATCH 500 and preserves merge semantics — the output class is only mapped to after persistence. (Covered by a dedicated test asserting a partial PATCH body leaves unsent fields untouched, guarding the ObjectMapper in 4.3.2 causes unintended validation failures on PATCH with input DTO #7886 regression class explicitly.)
  2. ObjectMapperOutputProcessor — map the persisted entity to getOutput()['class'] ?? getClass(), mirroring the read side (the feat(object-mapper): Honor the output class in ObjectMapperOutputProcessor on write operations #8420 one-liner, now safe).
  3. HttpResponseHeadersTrait — when original_data is a mapped non-resource output DTO, derive Location/Content-Location from the operation's item URI template (itemUriTemplate on Post, the operation's own uriTemplate on Patch/Put) via the existing item_uri_template IriConverter support. No template → previous behavior, no silent change.
  4. SerializerContextBuilder — set item_uri_template for mapped-output item write operations, so Patch/Put responses get a real @id (POST already gets it through itemUriTemplate, the mechanism the docs' BookCreated pattern relies on).

Answering the concerns from #8420

Location/Content-Location on a 201 would silently degrade to the collection IRI

Confirmed empirically — and fixed via (3). With itemUriTemplate declared, a POST now returns:

HTTP/1.1 201 Created
Location: /mapped_resource_with_outputs/1
Content-Location: /mapped_resource_with_outputs/1.jsonld

{"@id": "/mapped_resource_with_outputs/1", "@type": "MappedResourceWithOutput", "id": 1, "name": "a name"}

Without itemUriTemplate, behavior is unchanged from today (no silent degradation is introduced; failing loudly at metadata time could be a follow-up if preferred).

ObjectMapper is not the place to convert a resource into the output class; users would need #[Map] between the entity and every custom output

The processor maps entity → output, exactly like ObjectMapperProvider has done on reads since #7601 — one #[Map(source: MyEntity::class)] attribute on the output DTO, same contract as the read side. The serializer then receives an actual instance of the output class, so the output serialization path is taken naturally. ObjectMapperMetadataCollectionFactory already validates the output class mapping (@yceruto's objection from #7611 is resolved in current code).

We extended write-side mapping in #7879 and had to revert in #7892 because it broke PATCH

That revert concerned the provider mapping to the input class, pre-filling DTO properties before validation. This PR does the opposite: it makes the provider stop mapping to a non-resource class on writes (fixing the PATCH break described above), and only maps to the output class after persistence, where it can no longer affect what gets written.

BC note

The provider previously mapped the entity to the output class on write operations. As demonstrated above, that behavior was not usable (500 on PATCH, 400 on POST in the standard setup), but if someone's output DTO happened to be populate-compatible with their resource class, they would see changed behavior. Happy to discuss if this needs a changelog entry beyond the fix itself.

Tests

  • tests/Functional/MappedResourceOutputTest.php (new fixtures only): POST asserting response body is the DTO, @id/Location/Content-Location are the item IRI; PATCH asserting the response DTO, a real @id, and — against the database — that unsent fields are preserved.
  • tests/State/Provider/ObjectMapperProviderTest.php: one test updated, as it asserted the exact provider behavior shown broken above; a comment explains the new contract.
  • Existing MappingTest, ObjectMapper validation/unit and DTO/CRUD suites pass unchanged.

Documentation

If the approach is accepted I'll open the docs PR: output: + ObjectMapper on writes, the #[Map(source: ...)] contract for output DTOs, and the itemUriTemplate requirement for POST responses.

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.

1 participant