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é 1250 au lieu de 12.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 TypeError ou une MappingException sur 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 :

  1. un source explicite sur chaque #[Map] de propriété d'une ressource ;
  2. un transformateur à cible explicite pour chaque relation mappée ;
  3. des IRI calculées depuis la classe de la ressource, jamais depuis l'entité ;
  4. des entrées de PATCH sans valeur par défaut ;
  5. pendant la migration, writableLink: false et aucune relation sérialisée vers une entité séparée.

Chaque passage d'ObjectMapper a son piège, en lecture comme en écriture Doctrine ObjectMapper API Entité Doctrine Product Ressource DTO ProductResource Entité Doctrine Customer persist(), flush() Entrée du PATCH UpdateCustomerInput ObjectMapperProvider lecture : entité vers ressource ObjectMapperInputProcessor écriture : entrée vers entité 1 #[Map] sans source : ignoré 2 relation : un transformateur 3 IRI depuis l'entité : IRI anonyme 4 PATCH : absent n'est pas null 5 Pendant la migration : Customer encore exposée, Order déjà séparée une entrée typée par l'entité exposée : writableLink: false la relation Customer.orders vers Order : référence circulaire

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, sur Customer::$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 depuis OrderResource::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, ou map: false sur ses opérations.
  • Un source explicite sur chaque #[Map] de propriété d'une ressource.
  • Un transformateur à cible explicite pour chaque relation, et targetClass sur MapCollection en 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 IsNotNull seulement si null veut dire « inchangé ».
  • Un setter sur l'entité pour chaque champ qu'une entrée doit écrire.
  • writableLink: false sur 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