Conversation
audain-dg
force-pushed
the
feat/managed-entity-transform
branch
from
September 20, 2026 16:37
6ac61dd to
230285e
Compare
A resource may declare its mapping in the read direction only —
`#[Map(source: Entity::class)]` on the resource — which keeps the entity free
of any presentation concern. Reading works; writing does not. A relation typed
on another resource is never converted, reaches the entity's property as-is,
and PropertyAccess throws a 500:
Expected argument of type "?Author", "AuthorResource"
given at property path "author"
Declaring the reverse mapping only moves the failure: the mapper then builds a
fresh entity from the resource's scalars — right identifier, an instance
Doctrine has never seen — and the flush raises "A new entity was found through
the relationship". Cascading inserts a duplicate row instead.
ManagedEntityTransform resolves the related resource to the managed object it
stands for. Nothing is declared per relation: the managed class comes from the
related resource's state options, the identifiers from IdentifiersExtractor —
never assumed to be called `id`. A to-many arrives as an iterable and every
item is resolved.
Complements api-platform#7698: PersistProcessor::handleLazyObjectRelations() swaps an
unmanaged ENTITY for a reference, which is reached when the mapper already
produced one; it never converts a resource into an entity.
audain-dg
force-pushed
the
feat/managed-entity-transform
branch
from
September 20, 2026 17:05
230285e to
f6a8cf0
Compare
soyuka
requested changes
Sep 21, 2026
soyuka
left a comment
Member
There was a problem hiding this comment.
I think I understand the use case, it'd be preferable to have the functional use case in the PR description instead of the IA garbage.
I don't like the implementation of the transform, I think that this will be solved by the Symfony implementation where a property, being another mapped object, should be automatically be applied. For now I won't merge this I need more manual investigation.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A resource may declare its mapping in the read direction only —
#[Map(source: Entity::class)]on the resource — which is what keeps the entity free of any presentation concern. Reading works. Writing does not: a relation typed on another resource is never converted, reaches the entity's property as-is, andPropertyAccessthrows a 500:Declaring the reverse mapping instead does not fix it either — it moves the failure: the mapper then builds a fresh entity from the resource's scalars (right identifier, an instance Doctrine has never seen) and the flush raises
A new entity was found through the relationship. Cascading inserts a duplicate row instead.This PR adds
ApiPlatform\Doctrine\Common\State\ManagedEntityTransform, aTransformCallableInterfacethat resolves a related resource to the managed object it stands for:Nothing is declared per relation. The managed class comes from the related resource's state options (
getStateOptionsClass(), so ORM, ODM and Eloquent options are all read the same way), and the identifiers fromIdentifiersExtractorInterface— never assumed to be calledid, since a resource keyed on a natural code is just as valid, and a hardcoded->idsilently resolves nothing there. A to-many arrives as an iterable and every item is resolved, which is whatMapCollectionneeds on the write side.Relation to #7689 / #7698
Complementary, not overlapping.
PersistProcessor::handleLazyObjectRelations()swaps an unmanaged entity instance forgetReference(); it is reached when the mapper already produced an entity for the relation, which is the case when the DTO declares#[Map(target: Entity::class)]— theIssue7689ProductDtofixture, where the entity carries aMapattribute pointing back at the DTO. It never converts a resource into an entity, so thesource:-only direction still ends in thePropertyAccessortype error above.Scope, and a question for reviewers
Deliberately kept narrow: the transform has to be named on the property, exactly like any other
transform:. It could be applied automatically — for a property whose declared type is a resource backed by a managed class, anObjectMapperMetadataFactoryInterfacedecorator can injectnew Mapping(target: $property, transform: ..., targetClass: $entityClass), thetargetClasspinning it to the write direction. That removes the 500 with no user-facing declaration at all. I left it out of this PR because it changes behaviour for every mapped resource rather than adding an opt-in tool — happy to add it here if you would rather have it.Second, smaller question: the service is registered in
doctrine_orm.php. The class itself only needs aManagerRegistry, so the ODM could register it too under its own manager registry; say the word and I'll add it.Test plan
StateOptionTest::testPostWithRelationMappedFromTheResourceOnly, with fixtures whose entity carries no mapping attribute at all — the point of thesource:direction.vendor/bin/phpunit tests/Functional/Doctrine/StateOptionTest.php→OK (3 tests, 10 assertions).transform:from the fixture reproduces the 500 above, so the test pins the new behaviour rather than passing by accident.assertCount(1, ...)on the related repository): a detached rebuild would have inserted a second author.php-cs-fixer --dry-runclean on the touched files.The fixture resources are registered as services in the test app config. That is not incidental: an application collects the
#[Map]attributes of its resources through service autoconfiguration, and that is what feeds Symfony'sReverseClassObjectMapperMetadataFactoryclass map — and with it the entity → resource direction on read. Without it the fixtures only exercise half of the round trip.