The problem

In our article on separating Doctrine entities from API resources, the order service exposes DTO resources. We kept ObjectMapper for flat resources there, and explicit mappers for the rest. This article explains why, one pitfall at a time.

Since version 4.2, API Platform links a DTO resource to its entity with ObjectMapper, a Symfony component driven by the #[Map] attribute. A built-in provider and two built-in processors convert in both directions. There is no provider, processor or mapper left to write.

On the online shop, the first symptoms show up quickly, and often without any error:

  • A price returned as 1250 instead of 12.50. The transformation declared on the resource is no longer applied, for instance after an upgrade to Symfony 8.1.
  • A TypeError or a MappingException on a relation. With Symfony 7.4, ObjectMapper does not convert the customer or the lines of an order on its own.
  • IRIs in /.well-known/genid/.... They are computed from an entity that is no longer a resource, often in the tests.
  • A PATCH that erases a customer's phone number. The field was simply missing from the request body.
  • A circular reference during the migration. An entity still exposed points to an entity that no longer is.

The options

Explicit mappers everywhere

  • What it solves: every conversion is readable, tested PHP code, with no hidden behaviour.
  • What it costs: a provider, a processor and a mapper per resource, even when it copies its entity field by field.

ObjectMapper everywhere

  • What it solves: almost no code. Pagination, filters and Doctrine extensions stay active.
  • What it costs: an implicit behaviour, which depends on the Symfony version and on where the attributes are placed. Every relation needs a transformer.

ObjectMapper for flat resources, explicit mappers elsewhere

  • What it solves: ObjectMapper stays where it is simple, and relations stay in explicit code.
  • What it costs: two styles in the same codebase, and a boundary to draw resource by resource.

Our recommendation

ObjectMapper for flat resources, explicit mappers as soon as a relation comes into play, and five rules wherever ObjectMapper works.

The customer and the product are flat resources: a few fields, no exposed relation. In the previous article, they followed the order's model; here they move to ObjectMapper. The order keeps its provider, its processor and its mapper, which now hands the customer over to ObjectMapper.

A resource uses one or the other, never both. As soon as its class or its input carries #[Map], API Platform turns on automatic mapping, even around a custom provider. The map: false option of an operation turns it off.

The five rules:

  1. an explicit source on every property-level #[Map] of a resource;
  2. a transformer with an explicit target for every mapped relation;
  3. IRIs computed from the resource class, never from the entity;
  4. PATCH inputs without default values;
  5. during the migration, writableLink: false and no serialized relation to a separated entity.

Every ObjectMapper crossing has its own pitfall, on reads and on writes Doctrine ObjectMapper API Doctrine entity Product DTO resource ProductResource Doctrine entity Customer persist(), flush() PATCH input UpdateCustomerInput ObjectMapperProvider read: entity to resource ObjectMapperInputProcessor write: input to entity 1 #[Map] without source : ignored 2 relation: a transformer 3 IRI from the entity: anonymous IRI 4 PATCH: absent is not null 5 During the migration: Customer still exposed, Order already separated an input typed with the exposed entity: writableLink: false the Customer.orders relation to Order : circular reference

The trade-offs we accept

Two styles in the same codebase. One has to know why the product has no mapper and the order does. We write the rule for choosing in the project documentation.

Rules that nothing checks. A forgotten source raises no error, neither at boot time nor at runtime. Only functional tests that read every transformed field back will catch it.

A component that still moves. ObjectMapper has been stable since Symfony 7.4, but 8.1 changed the side it reads the attributes from. We read its CHANGELOG at every upgrade.

The signal that should trigger a review of this choice: a flat resource piling up transformers, or a badly mapped field found in production. It then moves to an explicit mapper.

Implementation

The examples target API Platform 4.4 (checked on 4.4.2), Symfony 7.4 and 8.1, Doctrine ORM 3 and PostgreSQL. ObjectMapper appeared in Symfony 7.3, as an experimental component. It has been stable since 7.4, and 8.1 adds the features pointed out below.

1. An explicit source on every property-level #[Map]

The Product entity stores its price in cents, in an integer. The API exposes it as a decimal string, like the quantities.

// src/ApiResource/ProductResource.php
namespace App\ApiResource;

use ApiPlatform\Doctrine\Orm\State\Options;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use App\Entity\Product;
use Symfony\Component\ObjectMapper\Attribute\Map;
use Symfony\Component\Uid\Uuid;

#[ApiResource(
    shortName: 'Product',
    operations: [new GetCollection(), new Get()],
    stateOptions: new Options(entityClass: Product::class),
)]
#[Map(source: Product::class)]
final readonly class ProductResource
{
    public function __construct(
        public Uuid $id,
        public string $sku,
        public string $name,
        // Without source: 'price', this transformation can be ignored.
        #[Map(source: 'price', transform: [self::class, 'formatPrice'])]
        public string $price,
    ) {
    }

    public static function formatPrice(int $cents): string
    {
        return sprintf('%d.%02d', intdiv($cents, 100), $cents % 100);
    }
}

No provider, no mapper: API Platform reads the entity through Doctrine, then converts it. Why source: 'price', when both properties have the same name?

ObjectMapper reads the attributes from one side only. If the source entity carries none, it reads those of the resource, and the transformation applies. If the entity carries some, it walks the entity's properties. On the resource side, it then only keeps the #[Map] whose source names the property being read.

With Symfony 8.1, the second case becomes the rule. Through autoconfiguration, FrameworkBundle builds a reverse class map from the class-level #[Map(source: ...)]. The entity thus gets metadata without carrying any attribute: without source, the transformation is ignored without any error, and the API returns "1250".

We reproduced it with the component alone, on 7.4.20 and on 8.1.8, reverse class map included. Without source, the resource returns "12.50" on 7.4 and "1250" on 8.1; with source, "12.50" in both cases. On 7.4, the same effect shows up if the entity carries a #[Map] itself.

The API Platform documentation's own example omits source.

2. Relations: a transformer that delegates to the mapper

Say the order is mapped by ObjectMapper, with its customer as a CustomerResource. On 7.4, the Customer entity carries no metadata: ObjectMapper copies it as is, and the resource constructor throws a TypeError. On 8.1, the reverse class map converts it based on the property type.

A transformer makes the target explicit, and works on both versions:

// src/Mapper/CustomerToResource.php
namespace App\Mapper;

use App\ApiResource\CustomerResource;
use Symfony\Component\ObjectMapper\ObjectMapperInterface;
use Symfony\Component\ObjectMapper\TransformCallableInterface;

final readonly class CustomerToResource implements TransformCallableInterface
{
    public function __construct(
        private ObjectMapperInterface $objectMapper,
    ) {
    }

    public function __invoke(mixed $value, object $source, ?object $target): CustomerResource
    {
        // The target is explicit: the Customer entity stays free of attributes.
        return $this->objectMapper->map($value, CustomerResource::class);
    }
}

On the resource, the property carries #[Map(source: 'customer', transform: CustomerToResource::class)]. Autoconfiguration registers the transformer, and ObjectMapper finds it by its class name.

For a collection, MapCollection applies the mapper to each item. On 7.4, it calls map() without a target: without a #[Map(target: ...)] on the OrderLine entity, it throws a MappingException. On 8.1, the targetClass option provides the target: new MapCollection(targetClass: OrderLineView::class).

Without that option, the 8.1 reverse class map is enough as long as a single class declares #[Map(source: OrderLine::class)]. With a second one, the mapping becomes ambiguous and throws an exception. And OrderLineView flattens the line and its product: it would still need transformers of its own.

This is what makes us keep an explicit mapper for the order.

3. IRIs: the resource class, never the entity

When the response only exposes a link to the customer, there is no need to build a whole CustomerResource. A transformer can produce the IRI directly:

// src/Mapper/CustomerIri.php
namespace App\Mapper;

use ApiPlatform\Metadata\IriConverterInterface;
use ApiPlatform\Metadata\UrlGeneratorInterface;
use App\ApiResource\CustomerResource;
use Symfony\Component\ObjectMapper\TransformCallableInterface;

final readonly class CustomerIri implements TransformCallableInterface
{
    public function __construct(
        private IriConverterInterface $iriConverter,
    ) {
    }

    public function __invoke(mixed $value, object $source, ?object $target): string
    {
        // $value is the Customer entity: pass the resource class and the identifier.
        return $this->iriConverter->getIriFromResource(
            CustomerResource::class,
            UrlGeneratorInterface::ABS_PATH,
            null,
            ['uri_variables' => ['id' => (string) $value->getId()]],
        );
    }
}

The property becomes public string $customer, with #[Map(source: 'customer', transform: CustomerIri::class)]. The downside: the OpenAPI schema only sees a string there, without the iri-reference format of relations, and the JSON-LD context no longer types it as @id.

Passing the entity, with getIriFromResource($value), looks simpler. But Customer is no longer a resource: API Platform then returns an anonymous IRI, in /.well-known/genid/.... Again, without any error.

The same trap awaits in the tests. findIriBy(), in ApiTestCase, only knows the classes managed by Doctrine: it looks up the entity, then computes its IRI, which is anonymous. The API then rejects it as input ("Invalid IRI").

So we build test IRIs from the UUIDs of the fixtures: '/customers/'.$customer->getId(). Finally, every resource referenced by IRI keeps a Get operation. Without it, API Platform adds a hidden operation: the IRI looks normal, but answers 404.

4. PATCH: a missing field is not a field set to null

For a PATCH, API Platform loads the entity, then maps the input onto it. A property that defaults to null therefore overwrites the customer's phone number when the field is missing from the body.

Symfony 8.1 adds the IsNotNull condition, which skips null values. But it also skips a null sent on purpose: the customer can no longer erase their number. Yet JSON Merge Patch tells the two cases apart.

We keep the rule from the previous article: no default value. ObjectMapper skips an uninitialized property on its own, on 7.4 as on 8.1, with no has() method to write.

// src/ApiResource/Input/UpdateCustomerInput.php
namespace App\ApiResource\Input;

use App\Entity\Customer;
use Symfony\Component\ObjectMapper\Attribute\Map;
use Symfony\Component\Validator\Constraints as Assert;

#[Map(target: Customer::class)]
final class UpdateCustomerInput
{
    // No default value: when missing, the field is not mapped; when null, it erases the number.
    #[Assert\Length(max: 20)]
    public ?string $phone;
}

The operation is declared on the resource: new Patch(input: UpdateCustomerInput::class). The mapper writes through the PropertyAccessor, which Symfony injects into it: the value goes through Customer::setPhone(). Without a setter or a public property, it would be ignored, here again without any error.

We keep IsNotNull for the fields where null means "unchanged".

5. During the migration: entities still exposed

A migration rarely happens in one go. For a while, Order is separated, but Customer is still an entity exposed through #[ApiResource]. Two pitfalls show up at this boundary.

An input that references the exposed entity. CreateOrderInput can type its customer with the entity. API Platform resolves the IRI through the Customer provider, and the processor receives an object managed by Doctrine.

// src/ApiResource/Input/CreateOrderInput.php (during the migration)
namespace App\ApiResource\Input;

use ApiPlatform\Metadata\ApiProperty;
use App\Entity\Customer;
use Symfony\Component\Validator\Constraints as Assert;

final readonly class CreateOrderInput
{
    public function __construct(
        #[Assert\NotBlank, Assert\Length(max: 32)]
        public string $reference,
        // Customer is still a resource: only its IRIs are accepted.
        #[ApiProperty(writableLink: false)]
        public Customer $customer,
        /** @var list<CreateOrderLineInput> */
        #[Assert\Count(min: 1), Assert\Valid]
        public array $lines,
    ) {
    }
}

Without writableLink: false, API Platform accepts a nested document as soon as the denormalization groups overlap those of the entity. With an @id, that document would modify the existing customer, saved by the order's flush(). With the option, only IRIs get through, as they do by default without groups.

An exposed entity that points to a separated entity. If Customer carries the inverse orders relation, API Platform has to serialize those orders. Since Order is no longer a resource, it handles it as a plain object. Through getLines() then OrderLine::getOrder(), it comes back to the starting order and ends with a CircularReferenceException.

Three ways out, from the quickest to the most durable:

  • #[Ignore], from the Symfony Serializer, on Customer::$orders: the property disappears from the response;
  • a normalizer for Customer, which replaces each order with its IRI, computed as in step 3 from OrderResource::class;
  • separating Customer in turn, which removes the cause.

What about API Platform 5.0?

5.0 was released on 17 September 2026. In the 5.0.1 code, the ObjectMapper integration is identical to the 4.4.2 one: same provider, same processors, same wiring. Only the ObjectMapperProcessor class goes away.

It had been deprecated since 4.3, which replaced it with two processors. ObjectMapperInputProcessor maps the input to the entity before the write; ObjectMapperOutputProcessor maps the written entity to the resource. In 4.4, they are already the ones running: only code that names the old class has to change.

Mind the documentation: the DTO page, in 4.4 as in 5.0, still describes ObjectMapperProcessor. The 5.0 upgrade guide, on the other hand, does announce its removal.

Checklist

  • ObjectMapper kept for flat resources, explicit mappers as soon as a relation shows up.
  • No #[Map] on a resource that has its own provider, or map: false on its operations.
  • An explicit source on every property-level #[Map] of a resource.
  • A transformer with an explicit target for every relation, and targetClass on MapCollection on 8.1.
  • IRIs computed from the resource class and an identifier, never from the entity.
  • Test IRIs built from the UUIDs of the fixtures, without findIriBy().
  • PATCH inputs without default values, and IsNotNull only where null means "unchanged".
  • A setter on the entity for every field an input has to write.
  • writableLink: false on inputs typed with an entity that is still exposed.
  • No serialized relation from an exposed entity to a separated one.
  • Tests that read every transformed field back, run again at every Symfony upgrade.

Sources