Le problème
Dans
notre article sur la séparation entre entités Doctrine et
ressources API, le service order expose des ressources DTO.
Nous y réservions ObjectMapper aux ressources plates, et des
mappers explicites au reste. Cet article explique pourquoi,
piège par piège.
Depuis la version 4.2, API Platform relie une ressource DTO
à son entité avec ObjectMapper, un composant Symfony piloté
par l'attribut #[Map]. Un provider et deux
processors intégrés font la conversion dans les deux sens.
Il ne reste ni provider, ni processor, ni mapper à écrire.
Sur la boutique en ligne, les premiers symptômes arrivent vite, et souvent sans erreur :
-
Un prix renvoyé
1250au lieu de12.50. La transformation déclarée sur la ressource n'est plus appliquée, par exemple après une montée vers Symfony 8.1. -
Une
TypeErrorou uneMappingExceptionsur une relation. En Symfony 7.4, ObjectMapper ne convertit pas seul le client ou les lignes d'une commande. -
Des IRI en
/.well-known/genid/.... Elles sont calculées depuis une entité qui n'est plus une ressource, souvent dans les tests. - Un PATCH qui efface le téléphone d'un client. Le champ était simplement absent du corps de la requête.
- Une référence circulaire pendant la migration. Une entité encore exposée pointe vers une entité qui ne l'est plus.
Les options
Des mappers explicites partout
- Ce qu'elle règle : chaque conversion est du code PHP lisible et testé, sans comportement caché.
- Ce qu'elle coûte : un provider, un processor et un mapper par ressource, même quand elle recopie son entité champ à champ.
ObjectMapper partout
- Ce qu'elle règle : presque aucun code. Pagination, filtres et extensions Doctrine restent actifs.
- Ce qu'elle coûte : un comportement implicite, qui dépend de la version de Symfony et de l'endroit où sont posés les attributs. Chaque relation demande un transformateur.
ObjectMapper pour les ressources plates, mappers explicites ailleurs
- Ce qu'elle règle : ObjectMapper reste là où il est simple, et les relations restent dans du code explicite.
- Ce qu'elle coûte : deux styles dans la même base de code, et une frontière à tracer ressource par ressource.
Notre recommandation
ObjectMapper pour les ressources plates, des mappers explicites dès qu'une relation entre en jeu, et cinq règles là où ObjectMapper travaille.
Le client et le produit sont des ressources plates : quelques champs, aucune relation exposée. Dans l'article précédent, ils suivaient le modèle de la commande ; ils passent ici à ObjectMapper. La commande garde son provider, son processor et son mapper, qui confie désormais le client à ObjectMapper.
Une ressource relève de l'un ou de l'autre, jamais des deux.
Dès que sa classe ou son entrée porte #[Map],
API Platform active le mapping automatique, y compris autour
d'un provider personnalisé. L'option
map: false d'une opération le désactive.
Les cinq règles :
-
un
sourceexplicite sur chaque#[Map]de propriété d'une ressource ; - un transformateur à cible explicite pour chaque relation mappée ;
- des IRI calculées depuis la classe de la ressource, jamais depuis l'entité ;
- des entrées de PATCH sans valeur par défaut ;
-
pendant la migration,
writableLink: falseet aucune relation sérialisée vers une entité séparée.
Les compromis assumés
Deux styles dans la même base de code. Il faut savoir pourquoi le produit n'a pas de mapper, et pourquoi la commande en a un. Nous écrivons la règle de choix dans la documentation du projet.
Des règles que rien ne vérifie. Un
source oublié ne lève aucune erreur, ni au
démarrage ni à l'exécution. Seuls des tests fonctionnels qui
relisent chaque champ transformé l'attrapent.
Un composant qui bouge encore. ObjectMapper est stable depuis Symfony 7.4, mais la 8.1 a changé le côté où il lit les attributs. Nous relisons son CHANGELOG à chaque montée de version.
Le signal qui doit faire réexaminer ce choix : une ressource plate qui accumule les transformateurs, ou un champ mal mappé découvert en production. Elle passe alors en mapper explicite.
Mise en œuvre
Les exemples ciblent API Platform 4.4 (vérifié sur la 4.4.2), Symfony 7.4 et 8.1, Doctrine ORM 3 et PostgreSQL. ObjectMapper est apparu dans Symfony 7.3, comme composant expérimental. Il est stable depuis la 7.4, et la 8.1 lui ajoute les fonctions signalées plus bas.
1. Un source explicite sur chaque
#[Map] de propriété
L'entité Product stocke son prix en centimes,
dans un entier. L'API l'expose en chaîne décimale, comme les
quantités.
// 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,
// Sans source: 'price', cette transformation peut être ignorée.
#[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);
}
}
Aucun provider, aucun mapper : API Platform lit l'entité par
Doctrine, puis la convertit. Pourquoi
source: 'price', alors que les deux propriétés
portent le même nom ?
ObjectMapper ne lit les attributs que d'un côté. Si l'entité
source n'en porte aucun, il lit ceux de la ressource, et la
transformation s'applique. Si l'entité en porte, il parcourt
ses propriétés et ne retient, côté ressource, que les
#[Map] dont le source nomme la
propriété lue.
Avec Symfony 8.1, le second cas devient la règle. Grâce à
l'autoconfiguration, le FrameworkBundle construit une carte
de classes inversée depuis les
#[Map(source: ...)] de classe. L'entité reçoit
ainsi des métadonnées sans porter d'attribut : sans
source, la transformation est ignorée sans
erreur, et l'API renvoie "1250".
Nous l'avons reproduit avec le composant seul, en 7.4.20 et
en 8.1.8, carte inversée comprise. Sans source,
la ressource renvoie "12.50" en 7.4 et
"1250" en 8.1 ; avec source,
"12.50" dans les deux cas. En 7.4, le même
effet apparaît si l'entité porte elle-même un
#[Map].
L'exemple de la documentation d'API Platform omet lui-même
source.
2. Les relations : un transformateur qui délègue au mapper
Supposons la commande mappée par ObjectMapper, avec son
client en CustomerResource. En 7.4, l'entité
Customer ne porte aucune métadonnée :
ObjectMapper la copie telle quelle, et le constructeur de la
ressource lève une TypeError. En 8.1, la carte
inversée la convertit d'après le type de la propriété.
Un transformateur rend la cible explicite, et fonctionne dans les deux 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
{
// La cible est explicite : l'entité Customer reste sans attribut.
return $this->objectMapper->map($value, CustomerResource::class);
}
}
Sur la ressource, la propriété porte
#[Map(source: 'customer', transform:
CustomerToResource::class)]. L'autoconfiguration enregistre le transformateur, et
ObjectMapper le retrouve par son nom de classe.
Pour une collection, MapCollection applique le
mapper à chaque élément. En 7.4, il appelle
map() sans cible : sans
#[Map(target: ...)] sur l'entité
OrderLine, il lève une
MappingException. En 8.1, l'option
targetClass donne la cible :
new MapCollection(targetClass:
OrderLineView::class).
Sans cette option, la carte inversée de la 8.1 suffit tant
qu'une seule classe déclare
#[Map(source: OrderLine::class)]. À la
deuxième, le mapping devient ambigu et lève une exception.
Et OrderLineView aplatit la ligne et son
produit : il lui faudrait encore ses propres
transformateurs.
C'est ce qui nous fait garder un mapper explicite pour la commande.
3. Les IRI : la classe de la ressource, jamais l'entité
Quand la réponse n'expose qu'un lien vers le client, inutile
de construire toute une CustomerResource. Un
transformateur peut produire l'IRI directement :
// 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 est l'entité Customer : passer la classe de la ressource et l'identifiant.
return $this->iriConverter->getIriFromResource(
CustomerResource::class,
UrlGeneratorInterface::ABS_PATH,
null,
['uri_variables' => ['id' => (string) $value->getId()]],
);
}
}
La propriété devient public string $customer,
avec
#[Map(source: 'customer', transform:
CustomerIri::class)]. Contrepartie : le schéma OpenAPI n'y voit qu'une chaîne,
sans le format iri-reference des relations, et
le contexte JSON-LD ne la type plus en @id.
Passer l'entité, avec
getIriFromResource($value), paraît plus simple.
Mais Customer n'est plus une ressource : API
Platform renvoie alors une IRI anonyme, en
/.well-known/genid/.... Là encore, sans erreur.
Le même piège guette les tests. findIriBy(),
dans ApiTestCase, ne connaît que les classes
gérées par Doctrine : il cherche l'entité, puis calcule son
IRI, qui est anonyme. L'API la refuse ensuite en entrée («
Invalid IRI »).
Nous construisons donc les IRI de test depuis les UUID des
fixtures : '/customers/'.$customer->getId().
Enfin, toute ressource référencée par IRI garde une
opération Get. Sans elle, API Platform ajoute
une opération cachée : l'IRI a l'air normale, mais répond
404.
4. PATCH : un champ absent n'est pas un champ à
null
Pour un PATCH, API Platform charge l'entité, puis mappe
l'entrée dessus. Une propriété qui vaut
null par défaut écrase donc le téléphone du
client quand le champ est absent du corps.
Symfony 8.1 ajoute la condition IsNotNull, qui
saute les valeurs null. Mais elle saute aussi
un null envoyé exprès : le client ne peut plus
effacer son numéro. JSON Merge Patch distingue pourtant les
deux cas.
Nous gardons la règle de l'article précédent : aucune valeur
par défaut. ObjectMapper ignore de lui-même une propriété
non initialisée, en 7.4 comme en 8.1, sans méthode
has() à écrire.
// 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
{
// Pas de valeur par défaut : absent, le champ n'est pas mappé ; à null, il efface le numéro.
#[Assert\Length(max: 20)]
public ?string $phone;
}
L'opération se déclare sur la ressource :
new Patch(input: UpdateCustomerInput::class).
Le mapper écrit par le PropertyAccessor, que Symfony lui
injecte : la valeur passe par
Customer::setPhone(). Sans setter ni propriété
publique, elle serait ignorée, là aussi sans erreur.
Nous réservons IsNotNull aux champs où
null veut dire « inchangé ».
5. Pendant la migration : les entités encore exposées
Une migration se fait rarement d'un bloc. Pendant un temps,
Order est séparée, mais
Customer reste une entité exposée par
#[ApiResource]. Deux pièges apparaissent à
cette frontière.
Une entrée qui référence l'entité exposée.
CreateOrderInput peut typer son client par
l'entité. API Platform résout l'IRI par le provider de
Customer, et le processor reçoit un objet géré
par Doctrine.
// src/ApiResource/Input/CreateOrderInput.php (pendant la 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 est encore une ressource : seules ses IRI sont acceptées.
#[ApiProperty(writableLink: false)]
public Customer $customer,
/** @var list<CreateOrderLineInput> */
#[Assert\Count(min: 1), Assert\Valid]
public array $lines,
) {
}
}
Sans writableLink: false, API Platform accepte
un document imbriqué dès que les groupes de dénormalisation
croisent ceux de l'entité. Avec un @id, ce
document modifierait le client existant, enregistré au
flush() de la commande. Avec l'option, seules
les IRI passent, comme par défaut sans groupes.
Une entité exposée qui pointe vers une entité
séparée.
Si Customer porte la relation inverse
orders, API Platform doit sérialiser ces
commandes. Comme Order n'est plus une
ressource, il la traite en objet ordinaire. Par
getLines() puis
OrderLine::getOrder(), il revient à la commande
de départ et finit sur une
CircularReferenceException.
Trois sorties, de la plus rapide à la plus durable :
-
#[Ignore], du Serializer de Symfony, surCustomer::$orders: la propriété disparaît de la réponse ; -
un normalizer pour
Customer, qui remplace chaque commande par son IRI, calculée comme au point 3 depuisOrderResource::class; -
la séparation de
Customerà son tour, qui supprime la cause.
Et avec API Platform 5.0 ?
La 5.0 est sortie le 17 septembre 2026. Dans le code de la
5.0.1, l'intégration d'ObjectMapper est identique à celle de
la 4.4.2 : même provider, mêmes processors, même câblage.
Seule la classe
ObjectMapperProcessor disparaît.
Elle était dépréciée depuis la 4.3, qui l'a remplacée par
deux processors.
ObjectMapperInputProcessor mappe l'entrée vers
l'entité avant l'écriture ;
ObjectMapperOutputProcessor mappe l'entité
écrite vers la ressource. En 4.4, ce sont déjà eux qui
tournent : seul un code qui cite l'ancienne classe doit
changer.
Attention à la documentation : la page sur les DTO, en 4.4
comme en 5.0, décrit encore
ObjectMapperProcessor. Le guide de migration
vers la 5.0, lui, annonce bien sa suppression.
Checklist
- ObjectMapper réservé aux ressources plates, mappers explicites dès qu'une relation apparaît.
-
Aucun
#[Map]sur une ressource qui a son propre provider, oumap: falsesur ses opérations. -
Un
sourceexplicite sur chaque#[Map]de propriété d'une ressource. -
Un transformateur à cible explicite pour chaque relation,
et
targetClasssurMapCollectionen 8.1. - Des IRI calculées depuis la classe de la ressource et un identifiant, jamais depuis l'entité.
-
Des IRI de test construites depuis les UUID des fixtures,
sans
findIriBy(). -
Des entrées de PATCH sans valeur par défaut, et
IsNotNullseulement sinullveut dire « inchangé ». - Un setter sur l'entité pour chaque champ qu'une entrée doit écrire.
-
writableLink: falsesur les entrées typées par une entité encore exposée. - Aucune relation sérialisée d'une entité exposée vers une entité séparée.
- Des tests qui relisent chaque champ transformé, relancés à chaque montée de Symfony.
Sources
-
API Platform,
Using Data Transfer Objects
:
stateOptions,#[Map]et mapping automatique. -
API Platform,
Operations
: opération
Getajoutée d'office, IRI/.well-known/genid/{id}. -
API Platform,
Serialization
:
readableLink,writableLinket documents imbriqués. -
API Platform,
Upgrade Guide
: suppression d'
ObjectMapperProcessoren 5.0. -
API Platform, code de la 4.4.2 :
ObjectMapperProvider,
ObjectMapperInputProcessor,
ObjectMapperMetadataCollectionFactory
(
map), IriConverter, AbstractItemNormalizer (denormalizeRelation) et ApiTestCase (findIriBy). - API Platform, notes de version de la 5.0.0 et CHANGELOG de la 5.0.1.
-
Symfony,
ObjectMapper
:
#[Map],MapCollection,IsNotNullet carte de classes inversée. -
Symfony, code d'ObjectMapper :
CHANGELOG,
ObjectMapper,
ReverseClassObjectMapperMetadataFactory,
ReverseMappingPass
et
FrameworkExtension
(autoconfiguration de
#[Map]). -
Symfony,
Serializer
:
#[Ignore]. - IETF, RFC 7396, JSON Merge Patch.