Skip to content

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

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

audain-dg wants to merge 3 commits 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

In my project the API resources are DTOs that keep the entities free of any
presentation concern, so they map in one direction only: #[Map(source: Author::class)]
on the resource, nothing on the entity.

Reading works, writing does not. POST /books with {"title": "…", "author": "/authors/1"}
denormalizes author into an AuthorResource, the mapper has no reason to convert it,
and it lands on Book::$author:

Expected argument of type "?App\Entity\Author", "App\Api\Resource\Author"
given at property path "author"

Adding the reverse mapping does not help, it moves the failure: the mapper then builds a
fresh Author from the resource's scalars — the right id, an instance Doctrine has never
seen — and the flush raises A new entity was found through the relationship. With
cascade persist it inserts a duplicate row.

I have been carrying the transform I posted in the comments in that project for a while.
This PR is that class, generalised:

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

The managed class comes from the related resource's state options and the identifier from
IdentifiersExtractor, so a resource keyed on a code rather than an id works too —
that is a real case for us, and hardcoding ->id silently resolves nothing there. A
to-many arrives as an iterable and every item is resolved.

It stays opt-in: you name it on the property like any other transform:. It could be
applied automatically for a property whose declared type is a resource backed by a managed
class, but that changes behaviour for every mapped resource, so I left it out.

This does not overlap #7698: handleLazyObjectRelations() swaps an unmanaged entity for a
reference, which only happens once the mapper has already produced an entity. It never
turns a resource into one, so the source:-only direction still ends in the type error
above.

Tested by StateOptionTest::testPostWithRelationMappedFromTheResourceOnly, on fixtures
whose entity carries no mapping attribute at all; dropping the transform: reproduces the
500. If you would rather the object mapper handle this by itself, that test still pins the
bug — keep it and drop my transform.

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.

@audain-dg

Copy link
Copy Markdown
Contributor Author

This what i have in on of my project as a temp fix @soyuka

<?php

use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\ObjectMapper\TransformCallableInterface;

/**
 * Resolves a related API Resource to its MANAGED Doctrine entity.
 *
 * The object mapper builds objects and has no identity map: left alone it
 * rebuilds a fresh entity from the resource's scalars — right id, an instance
 * Doctrine has never seen — and flushing raises "A new entity was found through
 * the relationship". Cascading would insert a duplicate instead.
 *
 * Nothing has to be declared per relation: the entity class and the identifier
 * are both read from the incoming resource's own metadata.
 *
 * Only the write direction needs this. Reading is handled by the framework —
 * a to-one by the object mapper itself (each projection declares
 * #[Map(source: Entity::class)]), a to-many by Symfony's MapCollection.
 *
 * @implements TransformCallableInterface<object, object>
 */
final readonly class ManagedEntityTransformer implements TransformCallableInterface
{
    public function __construct(
        private EntityManagerInterface $entityManager,
        private ResourceEntityLocator $locator,
    ) {
    }

    public function __invoke(mixed $value, object $source, ?object $target): mixed
    {
        // A to-many arrives as an iterable of resources: every item needs
        // resolving, or Doctrine is handed resource objects for an association.
        if (is_iterable($value)) {
            $resolved = [];

            foreach ($value as $key => $item) {
                $resolved[$key] = $this->resolve($item);
            }

            return $resolved;
        }

        return $this->resolve($value);
    }

    private function resolve(mixed $value): mixed
    {
        if (!\is_object($value)) {
            return $value;
        }

        $entityClass = $this->locator->entityClassOf($value::class);
        $identifier = $this->locator->identifierOf($value);

        if (null === $entityClass || null === $identifier) {
            return $value;
        }

        return $this->entityManager->find($entityClass, $identifier) ?? $value;
    }
}
```

```
<?php

use ApiPlatform\Doctrine\Orm\State\Options;
use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;

/**
 * Reads, from an API Resource, the two things needed to reach the Doctrine
 * entity behind it: which entity class, and which value identifies the row.
 *
 * The identifier is NOT assumed to be `id`. A resource keyed on `code` (or on
 * anything else) is just as valid, and hardcoding `id` makes the mapping
 * silently do nothing on those — the resource is then handed to Doctrine in
 * place of an entity.
 */
class ResourceEntityLocator
{
    /** @var array<class-string, class-string|false> */
    private array $entityClasses = [];

    /** @var array<class-string, string|false> */
    private array $identifiers = [];

    public function __construct(
        private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory,
        private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory,
        private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory,
    ) {
    }

    /**
     * @param class-string $resourceClass
     *
     * @return class-string|null
     */
    public function entityClassOf(string $resourceClass): ?string
    {
        if (isset($this->entityClasses[$resourceClass])) {
            return $this->entityClasses[$resourceClass] ?: null;
        }

        foreach ($this->resourceMetadataCollectionFactory->create($resourceClass) as $metadata) {
            foreach ($metadata->getOperations() ?? [] as $operation) {
                $stateOptions = $operation->getStateOptions();

                if ($stateOptions instanceof Options && $found = $stateOptions->getEntityClass()) {
                    return ($this->entityClasses[$resourceClass] = $found) ?: null;
                }
            }
        }

        $this->entityClasses[$resourceClass] = false;

        return null;
    }

    /**
     * The value of whichever property API Platform treats as the identifier.
     */
    public function identifierOf(object $resource): mixed
    {
        $property = $this->identifierPropertyOf($resource::class);

        return null === $property ? null : ($resource->{$property} ?? null);
    }

    /**
     * @param class-string $resourceClass
     */
    private function identifierPropertyOf(string $resourceClass): ?string
    {
        if (isset($this->identifiers[$resourceClass])) {
            return $this->identifiers[$resourceClass] ?: null;
        }

        foreach ($this->propertyNameCollectionFactory->create($resourceClass) as $property) {
            if (true === $this->propertyMetadataFactory->create($resourceClass, $property)->isIdentifier()) {
                return ($this->identifiers[$resourceClass] = $property) ?: null;
            }
        }

        $this->identifiers[$resourceClass] = false;

        return null;
    }
}

The doctrine-common suite runs standalone in CI, where neither
symfony/object-mapper nor api-platform/doctrine-orm is installed.
ManagedEntityTransform implements TransformCallableInterface and its test
builds an ORM State\Options, so both are fatal there:

    Class "ApiPlatform\Doctrine\Orm\State\Options" not found
    Interface "Symfony\Component\ObjectMapper\TransformCallableInterface" not found
    Tests: 38, Assertions: 127, Errors: 6.

symfony/object-mapper joins require-dev and conflict in doctrine-common and
doctrine-orm, the way api-platform/state already declares it.

The test moves to doctrine-orm: getStateOptionsClass() resolves the managed
class by instanceof against the concrete ORM, ODM and Eloquent options, so
nothing can stand in for one, and doctrine-common deliberately requires no
api-platform sibling package — only doctrine/orm and doctrine/mongodb-odm.
@audain-dg
audain-dg force-pushed the feat/managed-entity-transform branch from 280bdd1 to d0c0561 Compare September 21, 2026 18:07
@audain-dg

audain-dg commented Sep 21, 2026

Copy link
Copy Markdown
Contributor Author

@soyuka description rewritten with the real use case. The doctrine-common failures were mine too
the split package was missing symfony/object-mapper, fixed in d0c0561.

The doctrine-orm lowest and minimal-changes jobs install the released
doctrine-common instead of linking the workspace, so a test living there cannot
see a class this branch adds to doctrine-common:

    Error: Class "ApiPlatform\Doctrine\Common\State\ManagedEntityTransform" not found
    Tests: 303, Assertions: 988, Errors: 6.

Resolving the managed class through getStateOptionsClass() needs a concrete
State\Options, which is ORM, ODM or Eloquent and never doctrine-common, so the
class belongs with its test in doctrine-orm. doctrine-common is untouched again.
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