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
1250instead of12.50. The transformation declared on the resource is no longer applied, for instance after an upgrade to Symfony 8.1. -
A
TypeErroror aMappingExceptionon 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:
-
an explicit
sourceon every property-level#[Map]of a resource; - a transformer with an explicit target for every mapped relation;
- IRIs computed from the resource class, never from the entity;
- PATCH inputs without default values;
-
during the migration,
writableLink: falseand no serialized relation to a separated entity.
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, onCustomer::$orders: the property disappears from the response; -
a normalizer for
Customer, which replaces each order with its IRI, computed as in step 3 fromOrderResource::class; -
separating
Customerin 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, ormap: falseon its operations. -
An explicit
sourceon every property-level#[Map]of a resource. -
A transformer with an explicit target for every relation,
and
targetClassonMapCollectionon 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
IsNotNullonly wherenullmeans "unchanged". - A setter on the entity for every field an input has to write.
-
writableLink: falseon 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
-
API Platform,
Using Data Transfer Objects:
stateOptions,#[Map]and automatic mapping. -
API Platform,
Operations:
Getoperation added by default,/.well-known/genid/{id}IRIs. -
API Platform,
Serialization:
readableLink,writableLinkand nested documents. -
API Platform,
Upgrade Guide: removal of
ObjectMapperProcessorin 5.0. -
API Platform, 4.4.2 code:
ObjectMapperProvider,
ObjectMapperInputProcessor,
ObjectMapperMetadataCollectionFactory
(
map), IriConverter, AbstractItemNormalizer (denormalizeRelation) and ApiTestCase (findIriBy). - API Platform, 5.0.0 release notes and 5.0.1 CHANGELOG.
-
Symfony,
ObjectMapper:
#[Map],MapCollection,IsNotNulland reverse class map. -
Symfony, ObjectMapper code:
CHANGELOG,
ObjectMapper,
ReverseClassObjectMapperMetadataFactory,
ReverseMappingPass
and
FrameworkExtension
(autoconfiguration of
#[Map]). -
Symfony,
Serializer:
#[Ignore]. - IETF, RFC 7396, JSON Merge Patch.