Skip to content

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
<?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
<?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
<?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();
    }
}
yaml
# 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

  1. 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.

  2. Groupes de sérialisation par opérationbook:read, book:write, book:admin. Permet de cacher des champs selon le contexte (e.g. password jamais en lecture, internalNote seulement pour admin).

  3. Filtres custom — étendre AbstractFilter (ORM) ou AbstractContextAwareFilter. Pour ElasticSearch, utilise les filtres dédiés api-platform/elasticsearch.

  4. Sécurité par expressionsecurity: "is_granted('EDIT', object)" + Voter Symfony. securityPostDenormalize pour valider l'objet après binding (utile pour vérifier le nouveau owner).

  5. Sous-ressources — exposer /authors/{id}/books via une opération GetCollection sur Book avec uriTemplate: '/authors/{authorId}/books' et un Provider qui filtre.

  6. OpenAPI personnalisé — implémenter OpenApiFactoryInterface pour ajouter des endpoints, des schemas, des exemples. Utile pour documenter des routes hors API Platform.

🔄 Versions

API PlatformSymfonyPHP minNotes
2.75.4–6.47.4Annotations + attributes, DataProvider/DataPersister
3.06.1+8.1Réécriture metadata : Provider/Processor, attributs #[Get], #[Post]...
3.26.4+8.1Symfony 7 ready, JSON Merge Patch stable
3.36.4–7.x8.2GraphQL revamp, Mercure intégré
3.4 (LTS)7.0+8.2OpenAPI 3.1, dernière 3.x, support jusqu'à fin 2026
4.06.4–7.x8.2Hydra/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. DataProviderInterfaceProviderInterface (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:membermember, hydra:totalItemstotalItems, hydra:viewview, hydra:descriptiondescription. 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

  1. Sérialisation circulaireBook → Author → books[] → Book boucle infinie. Fix : groupes disjoints (author:read ne contient pas books) ou #[MaxDepth(1)] + enable_max_depth: true dans le contexte.

  2. N+1 queries — API Platform charge les collections lazy. Pour GetCollection, ajouter extensions Doctrine custom ou utiliser QueryItemExtensionInterface qui ajoute JOIN FETCH. Sinon : profiler montre 200 requêtes.

  3. security évalué trop tôtsecurity s'évalue avant denormalization, donc object est l'entité non modifiée. Pour valider le payload, utilise securityPostDenormalize.

  4. Filtres sur propriétés non-DoctrineSearchFilter fonctionne uniquement sur des colonnes mappées. Pour filtrer sur un champ calculé, crée un filter custom qui modifie le QueryBuilder.

  5. PATCH JSON Merge vs JSON Patch — par défaut API Platform 3.x utilise JSON Merge Patch (application/merge-patch+json). Si tu envoies application/json, ça renvoie 415. Configure formats: { json: ['application/json'] } côté PATCH ou utilise le bon Content-Type.

  6. 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.

  7. @id JSON-LD vs id JSON — en mode json pur, le champ id est inclus seulement s'il est dans les groupes. En jsonld, @id est ajouté automatiquement.

  8. Pagination cachée — par défaut hydra:view contient next/previous. Si tu désactives JSON-LD, ces métadonnées disparaissent → le client ne sait plus paginer. Utilise les headers Link ou expose un wrapper DTO.

🧪 Testing

php
<?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
<?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
<?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
<?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());
        }
    }
}
yaml
# config/packages/rate_limiter.yaml
framework:
    rate_limiter:
        psd2_api:
            policy: 'token_bucket'
            limit: 4
            rate: { interval: '1 second', amount: 4 }
php
<?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 : FilterExtensionOrderExtensionPaginationExtension → 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.

LevierQuandCoût/risque
paginationViaCursor (keyset)Listings profonds, flux infinisPerd le totalItems ; tri figé sur la clé cursor
paginationPartial: trueOn n'a pas besoin du totalPas de last/totalItems dans la vue Hydra
QueryCollectionExtensionInterface avec JOIN FETCHTuer les N+1 sur les relations sérialiséesCartesian product si plusieurs to-many → dédup
stateOptions: EntityClass + repo dédié read-modelLecture lourde ≠ écritureDouble 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
<?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.

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
<?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 denormalizationContext strict, un PATCH peut écrire isAdmin: true si le champ est mappé et writable. Toujours whitelister via groupes d'écriture, jamais blacklister.
  • IDOR via IRI : security: s'évalue sur object — mais pour une collection, object n'existe pas. Le filtrage multi-tenant doit se faire dans une query extension (côté SQL), pas dans security: (côté objet).
  • Énumération : 404 vs 403 fuit l'existence d'une ressource. security: renvoie 403 par défaut ; passe securityMessage + envisage 404 pour les ressources sensibles.
  • DoS par filtre : OrderFilter sur 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_complexity et max_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}/cancel n'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
<?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

Bibliothèque tech perso — Achref