You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
ObjectMapperOutputProcessor ignores operation output class #7940
Description
The ObjectMapperOutputProcessor::process() class (in src/State/Processor/ObjectMapperOutputProcessor.php) during the conversion of the object back to DTO uses $operation->getClass() as target for the objectMapper. Therefore ignores the output argument defined in the operation.
Taking a peek at ObjectMapperInputProcessor::process() (in src/State/Processor/ObjectMapperInputProcessor.php). The output class could be determined in the same way:
$class is only used to skip mapping when the incoming data isn't an instance of the input class. The actual map target is mapped_data (set upstream) or the stateOptions entity class — never input['class'] itself.
So the symmetric translation onto the output side would be a guard, not a new map target.
ObjectMapper and output: are two different mechanisms
ObjectMapper (Symfony component) maps ApiResource ↔ persistence entity — driven by #[Map] attributes and stateOptions.
output: CustomOutput::class declares a separate output DTO that the serialization pipeline is responsible for producing (SerializeProcessor / OutputMap), not ObjectMapper.
ObjectMapperOutputProcessor's job is to restore the API resource ($operation->getClass()) from the persisted entity so the rest of the chain (Serialize → Respond) can do its job. Mapping straight to output['class'] would skip the API resource entirely and require the user to declare #[Map] between Entity and CustomOutput — which is almost never what they have.
This was already proposed and rejected
#7611 proposed the exact same change against ObjectMapperProcessor and was closed. The design has since been refined through #7795, #7879 and #7892, settling on:
Component
Map target
ObjectMapperProvider
output['class'] ?? getClass() — read-side, terminates in serialization
ObjectMapperInputProcessor
mapped_data or getStateOptionsClass() — input['class'] is a guard
ObjectMapperOutputProcessor
getClass() — the API resource; output: is handled downstream
In particular, #7892 explicitly removedinput['class'] from the provider's chain because it broke PATCH; mirroring output['class'] into the output processor would create the symmetric breakage on the write path.
There's no stateOptions and no #[Map] configured — so ObjectMapper shouldn't be involved at all. output: CustomOutput::class is the legacy/Output DTO pipeline and should be honored by the serializer. If CustomOutput isn't being produced for you, that's a different bug (in the output transformation pipeline), not in ObjectMapperOutputProcessor.
If you can share a minimal failing test (or the actual response you get vs. what you expect, with full operation config) I'd be happy to help with a proper path.
Generated with Opus 4.7, reviewed and corrected by myself, with the addition of prior reference of PRs.
The reproduction I posted might not be the best. Let me show you what I'm currently trying to achieve with Symfony + Doctrine ORM + API Platform. While it might not be the ideal approach, as this is my first time working with API Platform and the lack of up-to-date documentation is causing me some headaches.
class ProfilePictureInput
{
#[Groups(['managed_file:write'])]
#[Assert\File(
maxSize: '1M',
mimeTypes: ['image/jpeg', 'image/png'],
maxSizeMessage: 'The file is too large ({{ size }} {{ suffix }}). Allowed maximum size is {{ limit }} {{ suffix }}.',
mimeTypesMessage: 'Please upload a valid image file (JPEG or PNG)',
)]
publicUploadedFile$picture;
}
Finally I have the processor that handles the file upload (I'm omiting some extra files like the UploadedFileDenormalizer and the MultipartDecoder).
class ManagedFileUploadProcessor implements ProcessorInterface
{
publicfunction__construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
protectedProcessorInterface$processor,
// ...
) {
}
publicfunctionprocess(mixed$data, Operation$operation, array$uriVariables = [], array$context = [])
{
$input = $context['data'] ?? null;
// Handle the upload, create the Managed file entity and assign it to $data$data = $managedFile;
return$this->processor->process($data, $operation, $uriVariables, $context);
}
}
To provide some more context, I want to be able to reuse the ManagedFile object in other entities. Therefore, I need it to be generic enough to also upload other types of files, such as documents or videos, to other entities (with their corresponding input DTO, and the file assignment to the entity, which is done later).
When I execute this operation, I expect to receive a ManagedFileResource object as a response; however, during the ObjectMapperOutputProcessor::process() process, this ManagedFile object is converted into a UserProfileResource object, which contains no data, since it's not possible to map from ManagedFile to UserProfileResource. Therefore, I ultimately get [] as a response.
Additional interesting fact.
This is probably not the intended way to do it, although replacing output: ManagedFileResource::class with class: ManagedFileResource::class and adding map: true to the UserProfileResource POST operation does the trick.
That's probably the simplest way. However, I don't consider it very scalable.
As the project grows, it could have more than 20 entities using ManagedFile, and defining their file upload logic within the generic ManagedFileResource (which is primarily for output) doe not seems correct. Nevertheless, I know I can solve this problem in several ways by my own.
Returning to the original issue, I think it's very frustrating and confusing that input and output behave differently. Perhaps that's why this kind of issue keeps getting reported. In fact, take a look at this example and the one below provided in the doc about output in DTOs.
What happens there: the entity BookEntity is transformed to a resource named Book, then we use output to avoid serialization groups. As the fields are a substract of the fields of the original resource it works just fine. Basically when serializing the Book resource, we say to symfony's serializer to use BookCollection for the JSON representation. We never use the ObjectMapper to transform the resource to an output.
Still you can definitely write your own set of rules using providers/processor (even system providers/processor). In your case you'd be just fine having this ManagedFile resource, using an IRI to reference that resource in your profile. Representing a UserProfile using a ManagedFile is semantically wrong, which is why API Platform doesn't work like this (doesn't mean you can not make it work if you like).
Closing as by-design. See prior comment for rationale: output: belongs to the serialization pipeline, not ObjectMapper; getInput()['class'] in InputProcessor is a guard, not a map target, so there's no symmetry to mirror. Same change was already rejected in #7611, and #7892 removed input['class'] from the provider chain because it broke PATCH — mirroring output['class'] here would create the symmetric breakage on the write path.
For the actual upload use case, declare the operation on ManagedFileResource directly with input: ProfilePictureInput::class and a custom provider/processor.
Activity
soyuka commentedon Apr 30, 2026
Thanks for the report — but this change shouldn't be made, and the symmetry you're drawing with
ObjectMapperInputProcessordoesn't hold. A few points:getInput()['class']in the input processor is a guard, not a targetIn
ObjectMapperInputProcessor::process():$classis only used to skip mapping when the incoming data isn't an instance of the input class. The actual map target ismapped_data(set upstream) or thestateOptionsentity class — neverinput['class']itself.So the symmetric translation onto the output side would be a guard, not a new map target.
ObjectMapperandoutput:are two different mechanismsObjectMapper(Symfony component) mapsApiResource ↔ persistence entity— driven by#[Map]attributes andstateOptions.output: CustomOutput::classdeclares a separate output DTO that the serialization pipeline is responsible for producing (SerializeProcessor / OutputMap), not ObjectMapper.ObjectMapperOutputProcessor's job is to restore the API resource ($operation->getClass()) from the persisted entity so the rest of the chain (Serialize → Respond) can do its job. Mapping straight tooutput['class']would skip the API resource entirely and require the user to declare#[Map]betweenEntityandCustomOutput— which is almost never what they have.This was already proposed and rejected
#7611 proposed the exact same change against
ObjectMapperProcessorand was closed. The design has since been refined through #7795, #7879 and #7892, settling on:ObjectMapperProvideroutput['class'] ?? getClass()— read-side, terminates in serializationObjectMapperInputProcessormapped_dataorgetStateOptionsClass()—input['class']is a guardObjectMapperOutputProcessorgetClass()— the API resource;output:is handled downstreamIn particular, #7892 explicitly removed
input['class']from the provider's chain because it broke PATCH; mirroringoutput['class']into the output processor would create the symmetric breakage on the write path.What you probably want
Looking at your repro:
#[ApiResource( operations: [new Post(output: CustomOutput::class)], )] class SomeResource { /* ... */ }There's no
stateOptionsand no#[Map]configured — soObjectMappershouldn't be involved at all.output: CustomOutput::classis the legacy/Output DTO pipeline and should be honored by the serializer. IfCustomOutputisn't being produced for you, that's a different bug (in the output transformation pipeline), not inObjectMapperOutputProcessor.If you can share a minimal failing test (or the actual response you get vs. what you expect, with full operation config) I'd be happy to help with a proper path.
Generated with Opus 4.7, reviewed and corrected by myself, with the addition of prior reference of PRs.
lordgretix commentedon Apr 30, 2026
The reproduction I posted might not be the best. Let me show you what I'm currently trying to achieve with Symfony + Doctrine ORM + API Platform. While it might not be the ideal approach, as this is my first time working with API Platform and the lack of up-to-date documentation is causing me some headaches.
I have these 2 doctrine entities:
Then I have the 2 corresponding entity DTOs + 1 additional input DTO:
#[ApiResource( shortName: 'ManagedFile', operations: [ new Get(), ], normalizationContext: ['groups' => ['managed_file:read']], denormalizationContext: ['groups' => ['managed_file:write']], stateOptions: new Options(entityClass: ManagedFile::class), )] #[Map(target: ManagedFile::class, source: ManagedFile::class)] class ManagedFileResource { #[Groups(['managed_file:read'])] #[ApiProperty(writable: false, identifier: true)] public int $id; #[Groups(['managed_file:read'])] public string $filename; #[Groups(['managed_file:read'])] public string $uri; #[Groups(['managed_file:read'])] public string $fileMime; #[Groups(['managed_file:read'])] public int $fileSize; #[Groups(['managed_file:read'])] public bool $status; #[Groups(['managed_file:write'])] public ?UploadedFile $file = null; }#[ApiResource( shortName: 'UserProfile', operations: [ // A bunch of other operations // ... new Post( uriTemplate: '/user-profiles/upload/picture', inputFormats: ['multipart' => ['multipart/form-data']], outputFormats: ['json' => ['application/json']], normalizationContext: ['groups' => ['managed_file:read']], denormalizationContext: ['groups' => ['managed_file:write']], input: ProfilePictureInput::class, output: ManagedFileResource::class, processor: ManagedFileProcessor::class, stateOptions: new Options(entityClass: ManagedFile::class), ), ], normalizationContext: ['groups' => ['user_profile:read'], 'skip_null_values' => false], denormalizationContext: ['groups' => ['user_profile:write']], stateOptions: new Options(entityClass: UserProfile::class), )] #[Map(target: UserProfile::class, source: UserProfile::class)] class UserProfileResource { #[Groups(['user_profile:read', 'user:details'])] #[ApiProperty(writable: false, identifier: true)] public int $id; #[Groups(['user_profile:read', 'user_profile:write', 'user:details'])] #[Assert\NotBlank(groups: ['user_profile:write'])] public ?string $name = null; #[Groups(['user_profile:read', 'user_profile:write'])] #[Assert\NotBlank(groups: ['user_profile:write', 'user:write'])] public ?string $familyName = null; #[Groups(['user_profile:read', 'user_profile:write', 'user:details'])] public ?ManagedFileResource $picture = null; }Finally I have the processor that handles the file upload (I'm omiting some extra files like the UploadedFileDenormalizer and the MultipartDecoder).
To provide some more context, I want to be able to reuse the ManagedFile object in other entities. Therefore, I need it to be generic enough to also upload other types of files, such as documents or videos, to other entities (with their corresponding input DTO, and the file assignment to the entity, which is done later).
When I execute this operation, I expect to receive a
ManagedFileResourceobject as a response; however, during theObjectMapperOutputProcessor::process()process, thisManagedFileobject is converted into aUserProfileResourceobject, which contains no data, since it's not possible to map from ManagedFile to UserProfileResource. Therefore, I ultimately get[]as a response.Additional interesting fact.
This is probably not the intended way to do it, although replacing
output: ManagedFileResource::classwithclass: ManagedFileResource::classand addingmap: trueto the UserProfileResource POST operation does the trick.soyuka commentedon May 1, 2026
Do not use input and output, declare the operation on the class that represents your json.
#[Post(input: ProfilePictureInput::class)] class ManagedFileResource {}Probably that its even easier so set your own provider to get the correct profile, and keep you processor.
lordgretix commentedon May 1, 2026
That's probably the simplest way. However, I don't consider it very scalable.
As the project grows, it could have more than 20 entities using ManagedFile, and defining their file upload logic within the generic ManagedFileResource (which is primarily for output) doe not seems correct. Nevertheless, I know I can solve this problem in several ways by my own.
Returning to the original issue, I think it's very frustrating and confusing that
inputandoutputbehave differently. Perhaps that's why this kind of issue keeps getting reported. In fact, take a look at this example and the one below provided in the doc about output in DTOs.soyuka commentedon May 1, 2026
What happens there: the entity
BookEntityis transformed to a resource namedBook, then we useoutputto avoid serialization groups. As the fields are a substract of the fields of the original resource it works just fine. Basically when serializing theBookresource, we say to symfony's serializer to useBookCollectionfor the JSON representation. We never use theObjectMapperto transform the resource to an output.Still you can definitely write your own set of rules using providers/processor (even system providers/processor). In your case you'd be just fine having this
ManagedFileresource, using an IRI to reference that resource in your profile. Representing a UserProfile using a ManagedFile is semantically wrong, which is why API Platform doesn't work like this (doesn't mean you can not make it work if you like).soyuka commentedon May 29, 2026
Closing as by-design. See prior comment for rationale:
output:belongs to the serialization pipeline, not ObjectMapper;getInput()['class']in InputProcessor is a guard, not a map target, so there's no symmetry to mirror. Same change was already rejected in #7611, and #7892 removedinput['class']from the provider chain because it broke PATCH — mirroringoutput['class']here would create the symmetric breakage on the write path.For the actual upload use case, declare the operation on
ManagedFileResourcedirectly withinput: ProfilePictureInput::classand a custom provider/processor.