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é.
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 :
-
UniqueConstraintViolationExceptionest mappée sur 422 par défaut, avec le même champdetail. -
ApiTestCasepasse dans le paquetapi-platform/test, sous l'espace de nomsApiPlatform\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
Getsur 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: trueet un 409 sans message SQL. - Chaque écriture relue par un GET séparé dans les tests.
Sources
-
API Platform,
DTO,
State Providers
et
Extensions
:
stateOptions, providers et extensions. -
API Platform,
Performance
: eager loading et
forceEager. -
API Platform,
Operations
: IRI sans opération
Get,/.well-known/genid/{id}. -
API Platform,
Content Negotiation,
Validation
et
Errors Handling
: PATCH,
collectDenormalizationErrors,exceptionToStatus. -
API Platform, code de la 4.4.2 :
ApiProperty
(
genId), EagerLoadingExtension, ErrorProvider (detail). - API Platform, notes de version de la 5.0.0, Upgrade Guide et CHANGELOG de la 5.0.1.
- Symfony, ObjectMapper, UID et Testing.
-
Doctrine,
types DBAL
:
decimallu en chaîne. - PHP, ReflectionProperty::isInitialized et propriétés readonly.
- IETF, RFC 7396, JSON Merge Patch.