API Platform
TL;DR — API Platform est un framework REST/GraphQL/JSON-LD construit par-dessus Symfony. Tu décris une ressource via l'attribut
#[ApiResource]et il génère CRUD, OpenAPI, hypermedia, filtres, pagination. En 3.x, le moteur a été réécrit autour de State Providers / State Processors (anciennement Data Providers / Persisters). À choisir quand tu veux un API REST hypermedia complet sans réinventer la roue ; à éviter pour des endpoints très custom orientés métier (mieux : raw controllers).
🧠 Mental model — ASCII diagram + analogy
HTTP Request
│
▼
┌─────────────────────────────────────────────────────────┐
│ API Platform Kernel (Symfony EventSubscribers) │
│ │
│ 1. ReadListener → State Provider (load data) │
│ 2. DeserializeListener → Serializer (groups) │
│ 3. ValidateListener → Validator (groups) │
│ 4. WriteListener → State Processor (persist) │
│ 5. SerializeListener → Serializer (groups out) │
│ 6. RespondListener → Response (JSON-LD, HAL...) │
└─────────────────────────────────────────────────────────┘
│
▼
HTTP Response (application/ld+json by default)Analogie : API Platform est à Symfony ce que Spring Data REST est à Spring. Tu déclares la forme de la ressource, le framework construit la mécanique HTTP (routes, codes statut, headers, contenu).
Diff clé 2.x → 3.x : avant on avait ItemDataProvider + CollectionDataProvider + DataPersister. En 3.x : un seul concept = ProviderInterface (lecture) et ProcessorInterface (écriture). Plus simple, plus testable.
🛠️ Code minimal — realistic snippet (PHP 8.2+)
<?php
// src/Entity/Book.php
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
use ApiPlatform\Metadata\ApiFilter;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity]
#[ApiResource(
operations: [
new GetCollection(normalizationContext: ['groups' => ['book:list']]),
new Get(normalizationContext: ['groups' => ['book:read']]),
new Post(
denormalizationContext: ['groups' => ['book:write']],
security: "is_granted('ROLE_LIBRARIAN')",
processor: \App\State\BookProcessor::class,
),
new Patch(
denormalizationContext: ['groups' => ['book:write']],
security: "is_granted('ROLE_LIBRARIAN') and object.owner == user",
),
new Delete(security: "is_granted('ROLE_ADMIN')"),
],
paginationItemsPerPage: 20,
)]
#[ApiFilter(SearchFilter::class, properties: ['title' => 'partial', 'author.name' => 'exact'])]
#[ApiFilter(OrderFilter::class, properties: ['publishedAt', 'title'])]
class Book
{
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
#[Groups(['book:list', 'book:read'])]
private ?int $id = null;
#[ORM\Column(length: 255)]
#[Groups(['book:list', 'book:read', 'book:write'])]
#[Assert\NotBlank, Assert\Length(min: 2, max: 255)]
private string $title;
#[ORM\ManyToOne(targetEntity: Author::class)]
#[Groups(['book:read', 'book:write'])]
private Author $author;
#[ORM\Column(type: 'datetime_immutable')]
#[Groups(['book:read'])]
private \DateTimeImmutable $publishedAt;
// getters/setters
}<?php
// src/State/BookProcessor.php — custom write logic (3.x style)
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Book;
use App\Service\Slugger;
use Doctrine\ORM\EntityManagerInterface;
final readonly class BookProcessor implements ProcessorInterface
{
public function __construct(
private EntityManagerInterface $em,
private Slugger $slugger,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): Book
{
\assert($data instanceof Book);
$data->setSlug($this->slugger->slug($data->getTitle()));
$this->em->persist($data);
$this->em->flush();
return $data;
}
}<?php
// src/State/PublishedBooksProvider.php — custom read logic
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Repository\BookRepository;
final readonly class PublishedBooksProvider implements ProviderInterface
{
public function __construct(private BookRepository $repo) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): iterable
{
// Apply filters from $context['filters'] manually if needed
return $this->repo->findPublished();
}
}# config/packages/api_platform.yaml
api_platform:
title: 'Library API'
version: '1.0.0'
formats:
jsonld: ['application/ld+json']
json: ['application/json']
docs_formats:
jsonld: ['application/ld+json']
json: ['application/json']
html: ['text/html']
defaults:
pagination_enabled: true
pagination_client_items_per_page: true
mapping:
paths: ['%kernel.project_dir%/src/Entity']🎯 Patterns courants
Resource ≠ Entity — pour les API publiques stables, crée des DTO (
#[ApiResource]sur une classe non-Doctrine) et un Provider/Processor qui convertit. Ça découple le contrat HTTP du schéma DB.Groupes de sérialisation par opération —
book:read,book:write,book:admin. Permet de cacher des champs selon le contexte (e.g.passwordjamais en lecture,internalNoteseulement pour admin).Filtres custom — étendre
AbstractFilter(ORM) ouAbstractContextAwareFilter. Pour ElasticSearch, utilise les filtres dédiésapi-platform/elasticsearch.Sécurité par expression —
security: "is_granted('EDIT', object)"+ Voter Symfony.securityPostDenormalizepour valider l'objet après binding (utile pour vérifier le nouveau owner).Sous-ressources — exposer
/authors/{id}/booksvia une opérationGetCollectionsurBookavecuriTemplate: '/authors/{authorId}/books'et un Provider qui filtre.OpenAPI personnalisé — implémenter
OpenApiFactoryInterfacepour ajouter des endpoints, des schemas, des exemples. Utile pour documenter des routes hors API Platform.
🔄 Versions
| API Platform | Symfony | PHP min | Notes |
|---|---|---|---|
| 2.7 | 5.4–6.4 | 7.4 | Annotations + attributes, DataProvider/DataPersister |
| 3.0 | 6.1+ | 8.1 | Réécriture metadata : Provider/Processor, attributs #[Get], #[Post]... |
| 3.2 | 6.4+ | 8.1 | Symfony 7 ready, JSON Merge Patch stable |
| 3.3 | 6.4–7.x | 8.2 | GraphQL revamp, Mercure intégré |
| 3.4 (LTS) | 7.0+ | 8.2 | OpenAPI 3.1, dernière 3.x, support jusqu'à fin 2026 |
| 4.0 | 6.4–7.x | 8.2 | Hydra/JSON-LD passe aux clés sans préfixe hydra:, OpenAPI typé (objets PHP au lieu de tableaux), Laravel support, JsonSchema strict |
Migration 2.x → 3.x : annotations YAML/XML restent supportées un temps mais attributes PHP 8 sont la voie. DataProviderInterface → ProviderInterface (signature simplifiée, un seul provide()). Rector recipe disponible : vendor/bin/rector process src --set api-platform-30.
Migration 3.x → 4.x (le piège qui casse les clients) : en JSON-LD, les métadonnées Hydra perdent le préfixe. hydra:member → member, hydra:totalItems → totalItems, hydra:view → view, hydra:description → description. Si ton front parse data['hydra:member'] en dur, il casse silencieusement (clé absente, tableau vide). Mitigation : négocier la version via le Accept profile, ou figer un mapper côté client. La factory OpenAPI manipule désormais des objets immuables (ApiPlatform\OpenApi\Model\*) avec des with*() au lieu de tableaux associatifs — tout OpenApiFactoryInterface custom doit être réécrit.
Symfony 5.4 → 6.x : pas d'impact direct API Platform sauf que la config framework.session.storage_id change → vérifier que framework.session.storage_factory_id est utilisée. Symfony 6.4 → 7.x : Security est Symfony\Bundle\SecurityBundle\Security (l'ancien Symfony\Component\Security\Core\Security est supprimé) — tes providers/processors qui l'injectent doivent pointer le bon namespace.
⚠️ Pitfalls
Sérialisation circulaire —
Book → Author → books[] → Bookboucle infinie. Fix : groupes disjoints (author:readne contient pasbooks) ou#[MaxDepth(1)]+enable_max_depth: truedans le contexte.N+1 queries — API Platform charge les collections lazy. Pour
GetCollection, ajouterextensionsDoctrine custom ou utiliserQueryItemExtensionInterfacequi ajouteJOIN FETCH. Sinon : profiler montre 200 requêtes.securityévalué trop tôt —securitys'évalue avant denormalization, doncobjectest l'entité non modifiée. Pour valider le payload, utilisesecurityPostDenormalize.Filtres sur propriétés non-Doctrine —
SearchFilterfonctionne uniquement sur des colonnes mappées. Pour filtrer sur un champ calculé, crée un filter custom qui modifie le QueryBuilder.PATCHJSON Merge vs JSON Patch — par défaut API Platform 3.x utilise JSON Merge Patch (application/merge-patch+json). Si tu envoiesapplication/json, ça renvoie 415. Configureformats: { json: ['application/json'] }côté PATCH ou utilise le bon Content-Type.IRI vs ID — API Platform retourne
/api/books/42(IRI), pas l'ID brut. Côté client, il faut envoyer l'IRI dans les relations ("author": "/api/authors/7") sinon 400.@idJSON-LD vsidJSON — en modejsonpur, le champidest inclus seulement s'il est dans les groupes. Enjsonld,@idest ajouté automatiquement.Pagination cachée — par défaut
hydra:viewcontientnext/previous. Si tu désactives JSON-LD, ces métadonnées disparaissent → le client ne sait plus paginer. Utilise les headersLinkou expose un wrapper DTO.
🧪 Testing
<?php
// tests/Api/BookTest.php
namespace App\Tests\Api;
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
use App\Entity\Book;
final class BookTest extends ApiTestCase
{
public function testGetCollection(): void
{
$response = static::createClient()->request('GET', '/api/books');
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['@context' => '/api/contexts/Book']);
$this->assertMatchesResourceCollectionJsonSchema(Book::class);
}
public function testCreateRequiresAuth(): void
{
static::createClient()->request('POST', '/api/books', [
'json' => ['title' => 'Foo'],
]);
$this->assertResponseStatusCodeSame(401);
}
}ApiTestCase étend WebTestCase et fournit un client HTTP (basé sur HttpClient) qui sait gérer JSON-LD. Combine avec Foundry ou zenstruck/foundry pour les fixtures.
🎬 Cas d'usage concrets
Scénario 1 — API banque exposée fintech (BNP Paribas Open Banking)
Une banque retail française doit exposer ses comptes et opérations via une API conforme DSP2/PSD2 à destination des agrégateurs (Bridge, Budget Insight, Powens). Le contrat est versionné, hypermedia, et doit supporter application/hal+json + application/problem+json. API Platform permet de modéliser Account, Transaction, Beneficiary en ressources avec ApiResource, et de générer l'OpenAPI 3.1 publié sur le portail développeur. Les Provider/Processor custom branchent le core bancaire (mainframe COBOL via une couche d'intégration Kafka). Les groupes de sérialisation séparent la vue agrégateur (sans IBAN complet) et la vue interne (PCI DSS). Le filtre IP whitelisting (mTLS eIDAS) est appliqué via un EventSubscriber sur KernelEvents::REQUEST. Le rate limiter (symfony/rate-limiter) plafonne à 4 appels/seconde par client OAuth2. L'équipe utilise api-platform/admin en interne pour debugger les ressources sans écrire de backoffice. Les tests d'intégration ApiTestCase valident chaque migration de schéma pendant les sprints de 2 semaines. Le gain est mesurable : passage de 14 jours à 3 jours pour exposer un nouveau type de produit (livret, PEA) depuis la modélisation jusqu'à la mise en production canary.
Scénario 2 — API e-commerce marketplace (Cdiscount, ManoMano)
Une marketplace française expose son catalogue produits et son SDK de vente aux marchands tiers (B2B2C). 50 000 SKU sont synchronisés via API REST hypermedia. API Platform sert la couche /api/products, /api/offers, /api/orders avec ApiResource. Les filtres SearchFilter et OrderFilter permettent aux marchands de paginer et trier. L'extension QueryCollectionExtensionInterface injecte un filtre automatique sur le seller_id extrait du token JWT (un marchand ne voit que ses propres offres). Le Subresource /api/products/{id}/offers retourne uniquement les offres actives. La pagination cursor-based via PaginationExtension custom évite les OFFSET coûteux sur les 50M de lignes. Mercure pousse les changements de stock en temps réel aux marchands abonnés via SSE. Le cache HTTP avec Cache-Control: public, s-maxage=60 est servi par Varnish devant l'API, et invalidé via WriteListener qui purge les tags product-{id}. Les tests ApiTestCase couvrent les scénarios d'isolation multi-tenant (un marchand ne peut pas accéder aux commandes d'un autre).
Scénario 3 — API ATS RH publique (Welcome to the Jungle, HelloWork)
Une plateforme ATS française expose son moteur de matching candidats/offres en SaaS. Les clients RH (cabinets de recrutement, ESN) accèdent à /api/candidates, /api/jobs, /api/applications via API Key. API Platform gère le CRUD, mais les endpoints métier (POST /api/applications/{id}/shortlist, POST /api/jobs/{id}/publish) sont déclarés en #[ApiResource(operations: [...])] avec des Processor custom. Le RGPD impose un endpoint DELETE /api/candidates/{id} qui purge le profil + ses CV stockés sur S3 + ses logs de matching (event sourcing). Les groupes de sérialisation distinguent candidate:public (recruteur externe), candidate:internal (équipe interne), et candidate:rgpd (export complet pour droit d'accès). Les webhooks sortants notifient l'ATS du client (Workday, SAP SuccessFactors) via symfony/webhook quand un candidat avance dans le pipeline. Le système gère 200 requêtes/seconde en pic (campagnes de jobs board syndiquées) grâce au cache Redis sur les listings.
🛠️ Exemple end-to-end
Cas : exposer une API de gestion de comptes bancaires pour agrégateurs DSP2 (lecture comptes, transactions, virements).
<?php
// src/Entity/Account.php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use App\State\AccountCollectionProvider;
use App\State\AccountItemProvider;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity]
#[ApiResource(
shortName: 'Account',
operations: [
new GetCollection(
uriTemplate: '/accounts',
provider: AccountCollectionProvider::class,
normalizationContext: ['groups' => ['account:read']],
security: "is_granted('ROLE_PSD2_AISP')",
),
new Get(
uriTemplate: '/accounts/{iban}',
provider: AccountItemProvider::class,
normalizationContext: ['groups' => ['account:read', 'account:detail']],
security: "is_granted('ACCOUNT_VIEW', object)",
),
],
paginationItemsPerPage: 25,
paginationMaximumItemsPerPage: 100,
)]
#[ApiFilter(SearchFilter::class, properties: ['currency' => 'exact'])]
class Account
{
#[ORM\Id]
#[ORM\Column(length: 34)]
#[Groups(['account:read'])]
public string $iban;
#[ORM\Column(length: 80)]
#[Groups(['account:read'])]
#[Assert\NotBlank]
public string $holderName;
#[ORM\Column(length: 3)]
#[Groups(['account:read'])]
public string $currency = 'EUR';
#[ORM\Column(type: 'decimal', precision: 14, scale: 2)]
#[Groups(['account:detail'])]
public string $balance = '0.00';
#[ORM\Column]
#[Groups(['account:detail'])]
public \DateTimeImmutable $lastSyncAt;
}<?php
// src/State/AccountCollectionProvider.php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Banking\CoreBankingClient;
use Symfony\Bundle\SecurityBundle\Security;
final readonly class AccountCollectionProvider implements ProviderInterface
{
public function __construct(
private CoreBankingClient $coreBanking,
private Security $security,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): iterable
{
$clientId = $this->security->getToken()?->getAttribute('psd2_client_id');
$consentId = $context['request']?->headers->get('Consent-ID')
?? throw new \DomainException('Consent-ID header required (PSD2)');
return $this->coreBanking->fetchAccountsForConsent($clientId, $consentId);
}
}<?php
// src/EventListener/RateLimitListener.php
declare(strict_types=1);
namespace App\EventListener;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
use Symfony\Component\RateLimiter\RateLimiterFactory;
#[AsEventListener(event: 'kernel.request', priority: 32)]
final readonly class RateLimitListener
{
public function __construct(private RateLimiterFactory $psd2ApiLimiter) {}
public function __invoke(RequestEvent $event): void
{
if (!str_starts_with($event->getRequest()->getPathInfo(), '/api/')) {
return;
}
$clientId = $event->getRequest()->headers->get('X-Client-ID', 'anonymous');
$limit = $this->psd2ApiLimiter->create($clientId)->consume();
if (!$limit->isAccepted()) {
throw new TooManyRequestsHttpException($limit->getRetryAfter()->getTimestamp());
}
}
}# config/packages/rate_limiter.yaml
framework:
rate_limiter:
psd2_api:
policy: 'token_bucket'
limit: 4
rate: { interval: '1 second', amount: 4 }<?php
// tests/Api/AccountResourceTest.php
declare(strict_types=1);
namespace App\Tests\Api;
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
final class AccountResourceTest extends ApiTestCase
{
public function testListAccountsRequiresConsentHeader(): void
{
$client = static::createClient();
$client->request('GET', '/api/accounts', [
'auth_bearer' => $this->getPsd2Token('AISP'),
]);
$this->assertResponseStatusCodeSame(400);
// AP 3.x : 'hydra:description'. AP 4.x : 'description' (préfixe hydra: supprimé).
$this->assertJsonContains(['hydra:description' => 'Consent-ID header required (PSD2)']);
}
public function testListAccountsReturnsScopedToConsent(): void
{
$client = static::createClient();
$client->request('GET', '/api/accounts', [
'auth_bearer' => $this->getPsd2Token('AISP'),
'headers' => ['Consent-ID' => 'consent-abc-123'],
]);
$this->assertResponseIsSuccessful();
$this->assertMatchesResourceCollectionJsonSchema(\App\Entity\Account::class);
}
}Couverture : ressource API exposée + provider branché core banking + rate limiter PSD2 + tests d'intégration. La même topologie s'étend à Transaction et PaymentInitiation (PISP).
🏭 Production — comment raisonne un staff engineer
API Platform en démo est trivial. En prod, la question n'est jamais « comment ça marche » mais « où la magie me coûte cher et qui la débugge à 3h du mat ». Quatre axes.
1. Perf : le coût caché des extensions Doctrine
Chaque GetCollection traverse les query extensions dans l'ordre : FilterExtension → OrderExtension → PaginationExtension → tes extensions custom. Le piège : la pagination par défaut utilise OFFSET/LIMIT + un COUNT(*) séparé. Sur une table de 50 M de lignes, OFFSET 1000000 scanne un million de lignes avant d'en jeter 999 980, et le COUNT(*) peut prendre des secondes.
| Levier | Quand | Coût/risque |
|---|---|---|
paginationViaCursor (keyset) | Listings profonds, flux infinis | Perd le totalItems ; tri figé sur la clé cursor |
paginationPartial: true | On n'a pas besoin du total | Pas de last/totalItems dans la vue Hydra |
QueryCollectionExtensionInterface avec JOIN FETCH | Tuer les N+1 sur les relations sérialisées | Cartesian product si plusieurs to-many → dédup |
stateOptions: EntityClass + repo dédié read-model | Lecture lourde ≠ écriture | Double mapping à maintenir |
Règle staff : mesure d'abord (?debug + profiler Symfony, ou doctrine.dbal.logging en staging). Le diagnostic N+1 typique : 1 requête pour la collection + N requêtes pour author de chaque livre. Fix surgical :
<?php
// src/Doctrine/BookCollectionExtension.php
namespace App\Doctrine;
use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use App\Entity\Book;
use Doctrine\ORM\QueryBuilder;
final class BookCollectionExtension implements QueryCollectionExtensionInterface
{
public function applyToCollection(
QueryBuilder $qb,
QueryNameGeneratorInterface $gen,
string $resourceClass,
?Operation $operation = null,
array $context = [],
): void {
if ($resourceClass !== Book::class) {
return;
}
$root = $qb->getRootAliases()[0];
$qb->addSelect('author')->leftJoin("$root.author", 'author'); // tue le N+1
}
}2. Cache HTTP : le vrai gain de scale
API Platform gère nativement l'invalidation par tags (Cache-Control: s-maxage + header Cache-Tags/xkey) avec Varnish ou Souin. À chaque écriture, le PurgeHttpCacheListener purge les tags des IRIs touchées. C'est ce qui transforme une API à 200 req/s en API à 20 000 req/s : 99 % des GET ne touchent jamais PHP.
new GetCollection(cacheHeaders: ['max_age' => 0, 'shared_max_age' => 3600]),
new Get(cacheHeaders: ['max_age' => 60, 'shared_max_age' => 3600]),Piège : la sécurité par security: + cache partagé = poison de cache. Une ressource protégée par utilisateur ne doit pas avoir de shared_max_age (sinon le user A reçoit la réponse cachée du user B). Règle : cache partagé seulement sur les ressources publiques ou segmentées par Vary: Authorization.
3. Observabilité : où sont mes spans
La stack subscribers est opaque. Pour tracer le temps réel passé dans provide() vs serialization vs validation, instrumente avec OpenTelemetry (open-telemetry/opentelemetry-auto-symfony) ou décore tes providers :
<?php
final readonly class TracedProvider implements ProviderInterface
{
public function __construct(
private ProviderInterface $inner, // #[AutowireDecorated] le provider réel
private TracerInterface $tracer,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
{
$span = $this->tracer->spanBuilder('api.provide.'.$operation->getShortName())->startSpan();
try {
return $this->inner->provide($operation, $uriVariables, $context);
} finally {
$span->end();
}
}
}Métriques à grapher : p99 par opération, taux de 4xx par client OAuth, hit ratio du cache HTTP, durée du COUNT(*) de pagination. Un pic de p99 sur GetCollection sans pic CPU = N+1 ou OFFSET profond.
4. Sécurité : la surface d'attaque que la magie ouvre
- Mass assignment : sans
denormalizationContextstrict, unPATCHpeut écrireisAdmin: truesi le champ est mappé et writable. Toujours whitelister via groupes d'écriture, jamais blacklister. - IDOR via IRI :
security:s'évalue surobject— mais pour une collection,objectn'existe pas. Le filtrage multi-tenant doit se faire dans une query extension (côté SQL), pas danssecurity:(côté objet). - Énumération :
404vs403fuit l'existence d'une ressource.security:renvoie403par défaut ; passesecurityMessage+ envisage404pour les ressources sensibles. - DoS par filtre :
OrderFiltersur une colonne non indexée = full scan déclenchable par n'importe qui. Whiteliste les propriétés filtrables et indexe-les. - GraphQL : profondeur de requête non bornée = bombe de complexité. Active
graphql.max_query_complexityetmax_query_depth.
🔁 Quand utiliser / éviter
Utiliser quand :
- API REST hypermedia (JSON-LD/Hydra) avec CRUD standard.
- Tu veux OpenAPI auto-généré et un client SDK gratuit (admin React, GraphQL).
- Backoffice interne où la rapidité d'itération > contrôle fin.
Éviter quand :
- API publique avec contrat ultra-stable et versionné (préfère raw controllers + OpenAPI rédigé à la main).
- Endpoints très métier (workflow, RPC) —
POST /orders/{id}/canceln'est pas CRUD. - Performance critique : la stack subscribers ajoute ~3-5ms par requête.
- Équipe junior qui doit comprendre comment Symfony route — API Platform masque trop.
🧩 Le pattern qui sépare junior et senior : DTO + read-model
Le réflexe junior est #[ApiResource] directement sur l'entité Doctrine. Ça marche en démo et devient une dette : le contrat HTTP se met à fuir le schéma DB (renommer une colonne casse l'API), les groupes de sérialisation deviennent un sapin de Noël, et tu ne peux plus exposer un champ calculé sans hack.
Le pattern senior : la ressource API est un DTO, pas l'entité. Un Provider fait la lecture (entité → DTO), un Processor fait l'écriture (DTO → mutation domaine).
<?php
// src/ApiResource/BookResource.php — DTO, zéro Doctrine
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\Post;
use App\State\BookResourceProvider;
use App\State\BookResourceProcessor;
use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource(
shortName: 'Book',
operations: [
new Get(provider: BookResourceProvider::class),
new Post(processor: BookResourceProcessor::class),
],
)]
final class BookResource
{
public ?int $id = null;
#[Assert\NotBlank, Assert\Length(min: 2, max: 255)]
public string $title = '';
public string $authorName = ''; // dénormalisé : pas d'IRI imposée au client
public ?\DateTimeImmutable $publishedAt = null;
}Pourquoi un staff engineer paie ce coût : (1) le contrat API devient versionnable indépendamment du modèle ; (2) tu testes la transformation en isolation ; (3) tu peux servir un read-model agrégé (issu de plusieurs tables, d'un cache, d'un autre microservice) sans tordre Doctrine ; (4) la validation porte sur le payload entrant, pas sur l'invariant d'entité. Le coût : deux classes de mapping de plus. Sur une API publique stable, c'est non négociable. Sur un backoffice interne jetable, c'est sur-ingénierie — d'où la section « éviter ».
🏋️ Exercices
Chaque exercice escalade. Fais-les dans l'ordre, sur une vraie app (
symfony new --webapp,composer require api).
1. CRUD + groupes — implémenter
Objectif : exposer une entité Article (titre, contenu, statut enum Draft|Published) avec des groupes de sérialisation tels que le contenu n'apparaît qu'en lecture détail (Get), jamais en liste (GetCollection). Indice : normalizationContext différent par opération ; un enum PHP 8.1 backed se sérialise nativement si tu le déclares #[ORM\Column(enumType: ArticleStatus::class)].
2. Provider DTO + read-model — découpler
Objectif : remplacer l'#[ApiResource] sur l'entité par un DTO ArticleResource alimenté par un Provider qui agrège le nombre de commentaires (commentCount) en une seule requête (pas de N+1). Indice : Provider qui injecte le repository et fait un SELECT a, COUNT(c) ... GROUP BY a ; mappe le résultat vers le DTO. Vérifie le nombre de requêtes via le profiler.
3. Filtre custom + sécurité multi-tenant — production-grade
Objectif : ajouter un filtre ?publishedAfter=2025-01-01 (impossible avec SearchFilter) et garantir qu'un auteur ne voit que ses propres articles, même sur GetCollection, via une query extension (pas via security:). Indice : étends AbstractFilter pour le DateFilter custom (manipule le QueryBuilder + déclare getDescription() pour OpenAPI) ; pour le multi-tenant, QueryCollectionExtensionInterface qui ajoute WHERE author = :currentUser. Pourquoi pas security: ? Parce qu'il n'a pas d'object sur une collection.
4. Cache HTTP + invalidation par tags — scale
Objectif : mettre un shared_max_age de 1h sur GetCollection, brancher Souin (ou Varnish), et vérifier qu'un POST invalide bien le cache de la collection (le nouvel article apparaît immédiatement). Puis prouve le poison de cache : mets une ressource protégée par user en cache partagé et montre la fuite. Indice : cacheHeaders dans l'opération ; api-platform/core émet les tags automatiquement. Pour la fuite : deux tokens, même URL, observe que le 2e user reçoit la réponse du 1er. Fix : retire le shared_max_age ou ajoute Vary: Authorization.
5. Casser puis réparer la migration 4.x — break-then-fix
Objectif : écris un test front (ou un assert) qui lit response['hydra:member']. Passe le projet en hypothèse AP 4.x (les clés perdent hydra:). Le test casse. Répare-le de deux façons et explique laquelle tu choisis en prod. Indice : option A — mettre à jour le parsing en dur (fragile, à refaire à chaque API) ; option B — un adaptateur de réponse côté client qui normalise member/hydra:member. Réponse senior : B + négociation de version, parce que tu ne contrôles pas le calendrier de migration de tous les consommateurs.
6. RPC propre dans un monde CRUD — design
Objectif : exposer POST /api/articles/{id}/publish (transition de workflow, pas un CRUD) sans tordre la sémantique REST. L'opération doit valider la transition (un Draft peut être publié, un Published non) et renvoyer 409 Conflict sinon. Indice : opération custom avec uriTemplate, input: false ou un DTO de commande, et un Processor qui appelle le symfony/workflow. La transition invalide lève une ConflictHttpException. Discute : pourquoi ne pas faire PATCH status=published ? (effet de bord non idempotent, side-effects métier, audit).
🎤 En entretien
Q : Pourquoi mapper #[ApiResource] sur un DTO plutôt que sur l'entité Doctrine ? R : Pour découpler le contrat HTTP du schéma de persistance — sinon un refactor DB casse l'API publique, et tu ne peux pas versionner ni servir un read-model agrégé. Le coût (un Provider/Processor de mapping) se justifie dès que l'API a des consommateurs externes ; sur un backoffice jetable, c'est de la sur-ingénierie.
Q : security: vs query extension pour le multi-tenant — quand l'un, quand l'autre ? R : security: opère sur un object chargé : parfait pour un Get/Patch/Delete sur un item. Mais sur une GetCollection, il n'y a pas d'object, donc l'isolation doit se faire en SQL via une QueryCollectionExtensionInterface qui injecte le WHERE tenant = :current. Mélanger les deux est l'erreur classique qui laisse fuiter les collections.
Q : Tu vois 200 requêtes SQL sur un GET /api/books. Diagnostic et fix ? R : N+1 classique — une requête collection + une par relation sérialisée (l'author de chaque livre). Fix : une QueryCollectionExtensionInterface avec JOIN FETCH/addSelect sur les relations chargées par les groupes, ou des fetch: EAGER ciblés. Je mesure d'abord via le profiler, et j'attention aux to-many multiples (produit cartésien → dédup ou requêtes séparées).
Q : Comment fais-tu monter cette API de 200 à 20 000 req/s sans réécrire ? R : Cache HTTP partagé avec invalidation par tags (Varnish/Souin devant API Platform) : les GET publics ne touchent plus PHP, les écritures purgent les tags des IRIs concernées. Attention au poison de cache : aucune ressource protégée par utilisateur en shared_max_age sans Vary: Authorization. Le reste se gagne sur la pagination keyset (tuer les OFFSET profonds) et l'indexation des colonnes filtrables.
🔗 Liens
- Doc officielle : https://api-platform.com/docs/core/
- Migration 2 → 3 : https://api-platform.com/docs/core/upgrade-guide/
- State Providers : https://api-platform.com/docs/core/state-providers/
- Filters : https://api-platform.com/docs/core/filters/
- Security : https://api-platform.com/docs/core/security/
- Repo : https://github.com/api-platform/core