Le problème

Le service order d'une boutique en ligne expose ses commandes avec API Platform. Le plus court est de poser #[ApiResource] sur l'entité Doctrine Order. Cela tient tant que l'API ressemble au schéma de la base.

Le schéma devient le contrat d'API. Renommer une colonne casse les clients. Chaque migration Doctrine devient une décision d'API.

Les groupes de sérialisation prolifèrent. order:read, order:write, customer:read... Pour savoir ce que renvoie une opération, il faut croiser plusieurs classes.

Validation d'écriture et persistance se mélangent. Les contraintes du POST vivent sur l'entité, et s'appliquent aussi aux imports et aux handlers de messages.

Les relations coûtent cher. Dès qu'une relation sort des groupes, une page de commandes charge ses lignes commande par commande : c'est le problème N+1. Une relation bidirectionnelle mal découpée en groupes finit en référence circulaire.

Les options

Exposer les entités directement

  • Ce qu'elle règle : tout est fourni, du CRUD à la pagination, avec très peu de code.
  • Ce qu'elle coûte : le couplage décrit plus haut, qui grandit avec chaque relation.

Des ressources DTO mappées par ObjectMapper

Depuis la version 4.2, API Platform relie une ressource DTO à une entité avec ObjectMapper, un composant Symfony piloté par des attributs #[Map].

  • Ce qu'elle règle : le contrat est séparé de l'entité, pour peu de code.
  • Ce qu'elle coûte : relations et cas particuliers passent par des transformateurs déclarés en attributs. Le comportement devient implicite.

Des ressources DTO avec provider, processor et mappers explicites

  • Ce qu'elle règle : chaque conversion est du code PHP ordinaire, lisible et testable. Avec stateOptions, le provider Doctrine d'API Platform fait toujours la requête : pagination, filtres et extensions restent actifs.
  • Ce qu'elle coûte : plus de classes, et un mapping écrit à la main.

Notre recommandation

Des entités Doctrine sans attribut API Platform, des ressources DTO reliées par stateOptions, et des providers, processors et mappers explicites.

La ressource est le contrat : une classe readonly, sans logique. L'entité reste un objet Doctrine classique, enregistré avec persist() et flush(). Entre les deux, deux passages seulement : le provider en lecture, le processor en écriture.

stateOptions: new Options(entityClass: Order::class) fait le pont : le provider Doctrine intégré interroge cette entité, avec pagination, filtres et extensions de requête.

Les écritures passent par des DTO d'entrée distincts : CreateOrderInput pour le POST, UpdateOrderInput pour le PATCH. Nous réservons ObjectMapper aux ressources plates, proches de leur entité.

Deux couches séparées : seuls le provider et le processor font le passage Couche Doctrine Passages Couche API Entités Order OrderLine Customer Product EntityManager persist(), flush() OrderResource sortie, readonly CreateOrderInput entrée du POST UpdateOrderInput entrée du PATCH OrderProvider lecture, via OrderMapper OrderProcessor écriture, via l' EntityManager réponse Les entités ne quittent jamais la couche Doctrine ; les ressources n'y entrent pas.

Les compromis assumés

Plus de classes. Une ressource demande un DTO de sortie, deux entrées, un provider, un processor, un mapper et une extension. En échange, le contrat évolue sans dépendre du schéma.

Un mapping à maintenir. Un champ ajouté touche l'entité, la ressource, le mapper et parfois une entrée. Les tests attrapent les oublis.

Le chargement des relations est à notre charge. L'eager loading intégré suit les groupes de sérialisation, absents ici. Notre extension doit suivre les relations que lit le mapper.

Le signal qui doit faire réexaminer ce choix : des mappers qui recopient champ à champ des ressources presque identiques à leurs entités. ObjectMapper devient alors plus économique.

Mise en œuvre

Les exemples ciblent API Platform 4.4 (relus sur le code de la 4.4.2), Symfony 7.4, Doctrine ORM 3 et PostgreSQL. Les écarts avec la 5.0 sont résumés à la fin.

1. Des entités sans attribut API Platform

L'identifiant est un UUID v7 généré en PHP, à la construction. Le statut est une chaîne, dont les valeurs sont des constantes.

// src/Entity/OrderStatus.php
namespace App\Entity;

final class OrderStatus
{
    public const DRAFT = 'draft';
    public const PLACED = 'placed';
    public const CANCELLED = 'cancelled';
}
// src/Entity/Order.php
namespace App\Entity;

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Types\UuidType;
use Symfony\Component\Uid\Uuid;

#[ORM\Entity]
#[ORM\Table(name: 'orders')]
class Order
{
    #[ORM\Id]
    #[ORM\Column(type: UuidType::NAME)]
    private Uuid $id;

    #[ORM\Column(length: 32, unique: true)]
    private string $reference;

    #[ORM\ManyToOne]
    #[ORM\JoinColumn(nullable: false)]
    private Customer $customer;

    /** @var Collection<int, OrderLine> */
    #[ORM\OneToMany(targetEntity: OrderLine::class, mappedBy: 'order', cascade: ['persist'])]
    private Collection $lines;

    #[ORM\Column(length: 20)]
    private string $status = OrderStatus::DRAFT;

    #[ORM\Column(length: 500, nullable: true)]
    private ?string $deliveryNote = null;

    public function __construct(string $reference, Customer $customer)
    {
        $this->id = Uuid::v7();
        $this->reference = $reference;
        $this->customer = $customer;
        $this->lines = new ArrayCollection();
    }

    public function addLine(Product $product, string $quantity): void
    {
        $this->lines->add(new OrderLine($this, $product, $quantity));
    }

    public function setDeliveryNote(?string $deliveryNote): void
    {
        $this->deliveryNote = $deliveryNote;
    }

    // Accesseurs : getId(), getReference(), getLines()...
}

OrderLine porte une relation ManyToOne vers Product et une quantité en decimal (précision 12, échelle 3). Doctrine lit ce type en chaîne : aucune perte de précision, contrairement à un flottant.

2. La ressource et ses opérations

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

use ApiPlatform\Doctrine\Orm\State\Options;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\ApiResource\Input\CreateOrderInput;
use App\ApiResource\Input\UpdateOrderInput;
use App\Entity\Order;
use App\Exception\OrderConflict;
use App\State\OrderProcessor;
use App\State\OrderProvider;
use Symfony\Component\Uid\Uuid;

#[ApiResource(
    shortName: 'Order',
    operations: [
        new GetCollection(),
        new Get(),
        new Post(input: CreateOrderInput::class),
        new Patch(input: UpdateOrderInput::class),
    ],
    provider: OrderProvider::class,
    processor: OrderProcessor::class,
    stateOptions: new Options(entityClass: Order::class),
    forceEager: false,
    collectDenormalizationErrors: true,
    exceptionToStatus: [OrderConflict::class => 409],
)]
final readonly class OrderResource
{
    public function __construct(
        public Uuid $id,
        public string $reference,
        public string $status,
        public CustomerResource $customer,
        /** @var list<OrderLineView> */
        #[ApiProperty(genId: false)]
        public array $lines,
        public ?string $deliveryNote,
    ) {
    }
}

shortName: 'Order' donne le chemin /orders. Provider et processor valent pour toutes les opérations, mais le POST ne lit rien : API Platform n'y appelle pas le provider.

Les lignes sont des objets OrderLineView (sku, productName, quantity), sans opération. En JSON-LD, un objet imbriqué qui n'est pas une ressource reçoit un @id anonyme en /.well-known/genid/... : genId: false le retire.

Le client, lui, est une ressource, référencée par son IRI /customers/{id}. Une ressource référencée par IRI doit exposer une opération Get. Sans elle, API Platform ajoute une opération interne : l'IRI est générée, mais répond 404.

Faute d'identifiant, l'IRI devient anonyme : /.well-known/genid/.... Une ressource DTO prend sa propriété id comme identifiant. Passer une entité à IriConverterInterface::getIriFromResource() donne aussi une IRI anonyme : l'entité n'est plus une ressource.

CustomerResource et ProductResource suivent donc le modèle de la commande, avec au moins operations: [new Get()], leur provider et leur mapper.

3. Le provider et le mapper

Le provider n'écrit aucune requête : il délègue aux providers Doctrine d'API Platform, puis convertit les entités.

// src/State/OrderProvider.php
namespace App\State;

use ApiPlatform\Metadata\CollectionOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\Pagination\PaginatorInterface;
use ApiPlatform\State\Pagination\TraversablePaginator;
use ApiPlatform\State\ProviderInterface;
use App\Mapper\OrderMapper;
use Symfony\Component\DependencyInjection\Attribute\Autowire;

final readonly class OrderProvider implements ProviderInterface
{
    public function __construct(
        #[Autowire(service: 'api_platform.doctrine.orm.state.collection_provider')]
        private ProviderInterface $collectionProvider,
        #[Autowire(service: 'api_platform.doctrine.orm.state.item_provider')]
        private ProviderInterface $itemProvider,
        private OrderMapper $mapper,
    ) {
    }

    public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
    {
        if (!$operation instanceof CollectionOperationInterface) {
            $order = $this->itemProvider->provide($operation, $uriVariables, $context);

            return null === $order ? null : $this->mapper->toResource($order);
        }

        $orders = $this->collectionProvider->provide($operation, $uriVariables, $context);
        $resources = [];
        foreach ($orders as $order) {
            $resources[] = $this->mapper->toResource($order);
        }

        if (!$orders instanceof PaginatorInterface) {
            return $resources;
        }

        return new TraversablePaginator(
            new \ArrayIterator($resources),
            $orders->getCurrentPage(),
            $orders->getItemsPerPage(),
            $orders->getTotalItems(),
        );
    }
}

Le TraversablePaginator conserve le total et les liens de pagination. Le mapper, lui, ne dépend pas d'API Platform.

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

use App\ApiResource\OrderLineView;
use App\ApiResource\OrderResource;
use App\Entity\Order;

final readonly class OrderMapper
{
    public function __construct(
        private CustomerMapper $customerMapper,
    ) {
    }

    public function toResource(Order $order): OrderResource
    {
        $lines = [];
        foreach ($order->getLines() as $line) {
            $product = $line->getProduct();
            $lines[] = new OrderLineView($product->getSku(), $product->getName(), $line->getQuantity());
        }

        return new OrderResource(
            $order->getId(),
            $order->getReference(),
            $order->getStatus(),
            $this->customerMapper->toResource($order->getCustomer()),
            $lines,
            $order->getDeliveryNote(),
        );
    }
}

4. Charger les relations en une requête

Le mapper lit le client, les lignes et leurs produits : sans jointure, Doctrine les charge commande après commande. L'eager loading d'API Platform ne joint que les relations couvertes par des groupes de sérialisation, absents ici.

// src/Doctrine/OrderEagerLoadingExtension.php
namespace App\Doctrine;

use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
use ApiPlatform\Doctrine\Orm\Extension\QueryItemExtensionInterface;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use App\Entity\Order;
use Doctrine\ORM\QueryBuilder;

final class OrderEagerLoadingExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface
{
    public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
    {
        $this->join($queryBuilder, $queryNameGenerator, $resourceClass);
    }

    public function applyToItem(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, array $identifiers, ?Operation $operation = null, array $context = []): void
    {
        $this->join($queryBuilder, $queryNameGenerator, $resourceClass);
    }

    private function join(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass): void
    {
        // Avec stateOptions, $resourceClass est la classe d'entité.
        if (Order::class !== $resourceClass) {
            return;
        }

        $order = $queryBuilder->getRootAliases()[0];
        $customer = $queryNameGenerator->generateJoinAlias('customer');
        $line = $queryNameGenerator->generateJoinAlias('line');
        $product = $queryNameGenerator->generateJoinAlias('product');

        $queryBuilder
            ->innerJoin("$order.customer", $customer)->addSelect($customer)
            ->leftJoin("$order.lines", $line)->addSelect($line)
            ->leftJoin("$line.product", $product)->addSelect($product);
    }
}

L'autoconfiguration enregistre l'extension. La jointure sur lines ne fausse pas la pagination : API Platform la détecte et adapte le paginateur de Doctrine.

Sur la ressource, forceEager: false limite l'extension intégrée aux associations déclarées fetch: 'EAGER'. Si des groupes de sérialisation apparaissent un jour, notre extension reste seule à décider des jointures.

5. Les entrées : POST et PATCH

Le DTO de création est readonly : le serializer le construit par son constructeur.

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

use App\ApiResource\CustomerResource;
use Symfony\Component\Validator\Constraints as Assert;

final readonly class CreateOrderInput
{
    public function __construct(
        #[Assert\NotBlank, Assert\Length(max: 32)]
        public string $reference,
        public CustomerResource $customer,
        /** @var list<CreateOrderLineInput> */
        #[Assert\Count(min: 1), Assert\Valid]
        public array $lines,
    ) {
    }
}

CreateOrderLineInput suit le même modèle : un ProductResource $product et une quantité en chaîne, contrôlée par Assert\Regex et Assert\Positive. Client et produits arrivent en IRI : API Platform les résout par le provider de leur ressource, et rejette une IRI inconnue.

Le PATCH suit JSON Merge Patch (RFC 7396), type application/merge-patch+json : un champ absent ne change pas, un champ à null est effacé. Il faut donc distinguer « absent » de « null ».

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

use Symfony\Component\Validator\Constraints as Assert;

final class UpdateOrderInput
{
    // Pas de valeur par défaut : un champ absent du corps reste non initialisé.
    #[Assert\Length(max: 500)]
    public ?string $deliveryNote;

    public function has(string $property): bool
    {
        return (new \ReflectionProperty($this, $property))->isInitialized($this);
    }
}

Le serializer n'initialise que les propriétés présentes dans le corps. isset() ne suffirait pas : il renvoie false pour un champ absent comme pour un champ à null. Le validateur de Symfony, lui, lit une propriété non initialisée comme null.

Ce DTO ne peut pas être readonly. Une propriété readonly ne s'initialise que depuis sa classe, donc par le constructeur, où un argument nullable absent reçoit null. La différence disparaîtrait.

6. Le processor et les erreurs

Le processor est le seul endroit qui écrit.

// src/State/OrderProcessor.php
namespace App\State;

use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\ApiResource\Input\CreateOrderInput;
use App\ApiResource\Input\UpdateOrderInput;
use App\ApiResource\OrderResource;
use App\Entity\Customer;
use App\Entity\Order;
use App\Entity\Product;
use App\Exception\OrderConflict;
use App\Mapper\OrderMapper;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\ORM\EntityManagerInterface;

/**
 * @implements ProcessorInterface<CreateOrderInput|UpdateOrderInput, OrderResource>
 */
final readonly class OrderProcessor implements ProcessorInterface
{
    public function __construct(
        private EntityManagerInterface $entityManager,
        private OrderMapper $mapper,
    ) {
    }

    public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): OrderResource
    {
        if ($data instanceof CreateOrderInput) {
            $order = new Order($data->reference, $this->entityManager->getReference(Customer::class, $data->customer->id));
            foreach ($data->lines as $line) {
                $order->addLine($this->entityManager->getReference(Product::class, $line->product->id), $line->quantity);
            }
            $this->entityManager->persist($order);
        } else {
            // Le provider a déjà chargé la commande, ou répondu 404.
            $order = $this->entityManager->find(Order::class, $uriVariables['id']);
            if ($data->has('deliveryNote')) {
                $order->setDeliveryNote($data->deliveryNote);
            }
        }

        try {
            $this->entityManager->flush();
        } catch (UniqueConstraintViolationException $e) {
            // Seule contrainte d'unicité hors clé primaire : reference.
            throw new OrderConflict('Une commande porte déjà cette référence.', previous: $e);
        }

        return $this->mapper->toResource($order);
    }
}

OrderConflict est une final class qui étend \RuntimeException. Pour le PATCH, find() lit la commande dans l'identity map de Doctrine, sans requête.

Deux POST avec la même référence, après un double clic, violent la contrainte d'unicité. Nous répondons 409 Conflict : la requête est valide, mais contredit l'état de la base.

Pourquoi ne pas mapper directement UniqueConstraintViolationException ? Pour un statut 4xx, API Platform renvoie le message de l'exception dans detail, même hors mode debug : ici, le message de PostgreSQL.

exceptionToStatus se déclare sur la ressource ou sur une opération, qui l'emporte. Enfin, collectDenormalizationErrors: true renvoie un 422 qui liste chaque champ mal typé, au lieu de s'arrêter au premier.

7. Vérifier la persistance par un GET

La réponse du POST est construite depuis l'objet en mémoire. Un champ oublié dans le mapping Doctrine y apparaît quand même. Nous vérifions donc chaque écriture par un GET séparé.

// tests/Api/OrderTest.php
namespace App\Tests\Api;

use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;

final class OrderTest extends ApiTestCase
{
    protected static ?bool $alwaysBootKernel = false;

    public function testCreatedOrderIsReadBackFromTheDatabase(): void
    {
        $client = static::createClient();
        // Client et produit créés par les fixtures du test.
        $response = $client->request('POST', '/orders', [
            'headers' => ['Content-Type' => 'application/ld+json'],
            'json' => [
                'reference' => 'WEB-1042',
                'customer' => '/customers/0199b1a0-7c3e-7d2a-9f10-2b6c1d4e5f60',
                'lines' => [['product' => '/products/0199b1a0-8d41-7e3b-a2c4-5d6e7f809a1b', 'quantity' => '2']],
            ],
        ]);
        self::assertResponseStatusCodeSame(201);

        // Le kernel redémarre entre deux requêtes : le GET relit la base.
        $client->request('GET', $response->toArray()['@id']);
        self::assertJsonContains(['reference' => 'WEB-1042', 'lines' => [['quantity' => '2.000']]]);
    }
}

La quantité envoyée 2 revient en 2.000, telle que PostgreSQL la stocke. La réponse du POST, construite en mémoire, affichait encore 2.

Et avec API Platform 5.0 ?

La 5.0 est sortie le 17 septembre 2026, le même jour que la 4.4. Dans le code de la 5.0.1, stateOptions, forceEager, collectDenormalizationErrors, exceptionToStatus et les interfaces d'extension sont inchangés. Deux écarts touchent cet article :

  • UniqueConstraintViolationException est mappée sur 422 par défaut, avec le même champ detail.
  • ApiTestCase passe dans le paquet api-platform/test, sous l'espace de noms ApiPlatform\Test.

Checklist

  • Aucun attribut API Platform sur les entités.
  • Des ressources DTO reliées à leur entité par stateOptions.
  • Un provider qui délègue au provider Doctrine et garde la pagination.
  • Une opération Get sur toute ressource référencée par IRI.
  • Une extension qui charge les relations lues par le mapper.
  • Des entrées distinctes pour POST et PATCH, sans valeur par défaut pour le PATCH.
  • collectDenormalizationErrors: true et un 409 sans message SQL.
  • Chaque écriture relue par un GET séparé dans les tests.

Sources