Skip to content

feat(doctrine): resolve a related resource to its managed entity - #8547

Open
audain-dg wants to merge 1 commit into
api-platform:mainfrom
audain-dg:feat/managed-entity-transform
Open

audain-dg wants to merge 1 commit into
api-platform:mainfrom
audain-dg:feat/managed-entity-transform

Conversation

@audain-dg

@audain-dg audain-dg commented Sep 20, 2026

Copy link
Copy Markdown
Contributor
Q A
Branch? main
Tickets -
License MIT
Doc PR api-platform/docs#2345

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, and PropertyAccess throws a 500:

POST /managed_relation_books  {"title": "book", "author": "/managed_relation_authors/1"}

→ 500  Expected argument of type "?ManagedRelationAuthor",
       "ManagedRelationAuthorResource" given at property path "author"

PropertyAccessor::throwInvalidArgumentException   PropertyAccessor.php:124
ObjectMapper->setValue                            ObjectMapper.php:246
ApiPlatform\State\ObjectMapper\ObjectMapper->map  ObjectMapper.php:44
ObjectMapperInputProcessor->process               ObjectMapperInputProcessor.php:57

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, a TransformCallableInterface that resolves a related resource to the managed object it stands for:

#[Map(target: 'author', transform: ManagedEntityTransform::class)]
public ?ManagedRelationAuthorResource $author = null;

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 from IdentifiersExtractorInterface — never assumed to be called id, since a resource keyed on a natural code is just as valid, and a hardcoded ->id silently resolves nothing there. A to-many arrives as an iterable and every item is resolved, which is what MapCollection needs on the write side.

Relation to #7689 / #7698

Complementary, not overlapping. PersistProcessor::handleLazyObjectRelations() swaps an unmanaged entity instance for getReference(); 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)] — the Issue7689ProductDto fixture, where the entity carries a Map attribute pointing back at the DTO. It never converts a resource into an entity, so the source:-only direction still ends in the PropertyAccessor type 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, an ObjectMapperMetadataFactoryInterface decorator can inject new Mapping(target: $property, transform: ..., targetClass: $entityClass), the targetClass pinning 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 a ManagerRegistry, so the ODM could register it too under its own manager registry; say the word and I'll add it.

Test plan

  • New functional test StateOptionTest::testPostWithRelationMappedFromTheResourceOnly, with fixtures whose entity carries no mapping attribute at all — the point of the source: direction.
  • vendor/bin/phpunit tests/Functional/Doctrine/StateOptionTest.phpOK (3 tests, 10 assertions).
  • Ablation: dropping the transform: from the fixture reproduces the 500 above, so the test pins the new behaviour rather than passing by accident.
  • The test asserts the managed identity too (assertCount(1, ...) on the related repository): a detached rebuild would have inserted a second author.
  • php-cs-fixer --dry-run clean 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's ReverseClassObjectMapperMetadataFactory class map — and with it the entity → resource direction on read. Without it the fixtures only exercise half of the round trip.

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
audain-dg force-pushed the feat/managed-entity-transform branch from 230285e to f6a8cf0 Compare September 20, 2026 17:05

@soyuka soyuka left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

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