TL;DRCe choix se fait en quinze minutes au deuxième jour d'un projet, et il structure les trois années suivantes. Voici le même endpoint écrit des deux façons, les critères qui tranchent vraiment, et ce que chaque approche coûte sur la durée.
La réponse en 4 lignes#
- Moins de cinq ressources, un seul front maison, une équipe qui ne connaît pas API Platform → contrôleurs. Vous n'amortirez jamais la courbe d'apprentissage.
- Plus de quinze ressources CRUD, des consommateurs tiers, un besoin de documentation OpenAPI qui ne ment pas → API Platform. Le code répétitif que vous n'écrivez pas est la moitié du gain ; la documentation générée est l'autre moitié.
- Un contrat d'API qui diverge franchement du modèle métier → API Platform avec des DTO, ou contrôleurs. Mais pas API Platform branché directement sur vos entités Doctrine.
- Le cas le plus fréquent en réalité → les deux, dans le même projet. Voir la section sur l'approche hybride.
Introduction#
Ce choix se fait en général le deuxième jour d'un projet, en quinze minutes, entre deux décisions plus urgentes. Et il structure les trois années suivantes.
Le problème n'est pas qu'on choisisse mal, c'est qu'on choisit au moment où les deux options se valent. Exposer une liste de produits paginée prend vingt minutes dans les deux cas. L'écart n'apparaît qu'à la première exigence hors cadre : un partenaire qui veut un champ calculé, une application mobile qui a besoin d'une autre représentation que le back-office. À ce moment-là, une approche vous demande d'apprendre son mécanisme d'extension, l'autre d'écrire — encore — cent lignes déjà écrites ailleurs.
Cet article montre le même endpoint des deux façons, puis donne des critères pour trancher. Pas de vainqueur universel : nous avons livré des deux manières, et regretté les deux, dans des contextes différents.
Ce que fait réellement API Platform#
API Platform lit des attributs PHP sur vos classes et en dérive un ensemble d'opérations HTTP. Ce qui est réellement automatique :
- L'exposition des opérations : déclarer
#[ApiResource]crée les routes, les contrôleurs et les réponses pourGET,POST,PATCH,DELETE. - La négociation de contenu : JSON-LD, JSON:API, HAL, JSON brut, selon l'en-tête
Accept. - La pagination, activée par défaut sur les collections, configurable par opération (documentation).
- La validation, branchée sur les contraintes du composant Validator de Symfony, avant l'écriture (documentation).
- La documentation OpenAPI, dérivée des mêmes métadonnées (documentation).
Ce qui ne l'est pas, et où les projets dérapent :
- Le choix des champs exposés. Sans groupes de sérialisation, vous publiez toute votre entité, relations comprises. C'est une décision à prendre ressource par ressource (documentation).
- Les filtres. Rien n'est filtrable tant que vous ne l'avez pas déclaré.
- Toute logique qui n'est pas « lire ou écrire une ressource ». Il faut alors écrire un provider ou un processor — les points d'extension d'API Platform, qui remplacent les DataProvider et DataPersister des versions 2.x.
- Les performances sur les graphes profonds. Les jointures ne se devinent pas.
Changement de version à connaître. En 4.4, les filtres historiques (
SearchFilter,BooleanFilter,NumericFilter,OrderFilterdéclarés via#[ApiFilter]) sont dépréciés au profit de filtres granulaires déclarés en paramètres d'opération. Une commandeapi:upgrade-filterest fournie pour migrer le code existant. La plupart des tutoriels en ligne montrent encore l'ancienne forme.
Le même endpoint, écrit des deux façons#
Cas d'usage identique des deux côtés : une ressource Produit avec pagination, filtrage par catégorie, validation à l'écriture et deux représentations — le back-office voit le prix d'achat, l'application mobile seulement le prix de vente.
Version API Platform#
<?php
// src/Entity/Product.php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\ExactFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\QueryParameter;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity]
#[ApiResource(
operations: [
new GetCollection(
uriTemplate: '/admin/products',
normalizationContext: ['groups' => ['product:admin']],
paginationItemsPerPage: 30,
parameters: [
'category' => new QueryParameter(
filter: ExactFilter::class,
property: 'category.slug',
),
],
),
new GetCollection(
uriTemplate: '/mobile/products',
normalizationContext: ['groups' => ['product:mobile']],
paginationItemsPerPage: 20,
),
new Post(
uriTemplate: '/admin/products',
denormalizationContext: ['groups' => ['product:write']],
normalizationContext: ['groups' => ['product:admin']],
),
],
)]
class Product
{
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
#[Groups(['product:admin', 'product:mobile'])]
private ?int $id = null;
#[ORM\Column(length: 180)]
#[Groups(['product:admin', 'product:mobile', 'product:write'])]
#[Assert\NotBlank]
#[Assert\Length(max: 180)]
private string $name = '';
#[ORM\Column]
#[Groups(['product:admin', 'product:mobile', 'product:write'])]
#[Assert\Positive]
private int $priceCents = 0;
/** Jamais exposé à l'application mobile. */
#[ORM\Column]
#[Groups(['product:admin', 'product:write'])]
#[Assert\PositiveOrZero]
private int $costCents = 0;
#[ORM\ManyToOne]
#[ORM\JoinColumn(nullable: false)]
#[Groups(['product:admin', 'product:write'])]
private ?Category $category = null;
// getters et setters omis
}Une classe, trois opérations, deux représentations. Pagination, documentation OpenAPI, validation et filtrage viennent avec.
Ce qui est caché : presque tout le chemin d'exécution. Entre requête et réponse, un provider charge, le serializer applique les groupes, un processor écrit. Quand la sortie ne correspond pas à l'attente, il faut remonter cette chaîne — c'est là que la courbe d'apprentissage se paie.

Les deux boîtes extensibles sont le provider et le processor. Tout le reste est fourni.
Note de version. La déclaration de filtres par
parameters:est la forme recommandée par la documentation. Sur une base existante en 4.3 ou antérieure, vous trouverez plutôt#[ApiFilter(SearchFilter::class, properties: ['category.slug' => 'exact'])], qui fonctionne toujours en 4.4 mais émet une dépréciation. [À vérifier sur votre version : la disponibilité deExactFiltercomme chaîne de classe dépend de l'enregistrement des filtres en services, automatique dans une installation standard via Flex.]
Version contrôleur classique#
<?php
// src/Controller/Api/AdminProductController.php
declare(strict_types=1);
namespace App\Controller\Api;
use App\Dto\ProductInput;
use App\Repository\ProductRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/admin/products', name: 'api_admin_products_')]
final class AdminProductController extends AbstractController
{
public function __construct(
private readonly ProductRepository $products,
) {
}
#[Route('', name: 'list', methods: ['GET'])]
public function list(Request $request): JsonResponse
{
$page = max(1, $request->query->getInt('page', 1));
$perPage = 30;
$paginator = $this->products->paginateByCategory(
$request->query->get('category'),
$page,
$perPage,
);
$total = \count($paginator);
return $this->json(
[
'items' => iterator_to_array($paginator),
'page' => $page,
'perPage' => $perPage,
'total' => $total,
'pages' => (int) ceil($total / $perPage),
],
context: ['groups' => ['product:admin']],
);
}
#[Route('', name: 'create', methods: ['POST'])]
public function create(#[MapRequestPayload] ProductInput $input): JsonResponse
{
$product = $this->products->createFromInput($input);
return $this->json($product, 201, context: ['groups' => ['product:admin']]);
}
}<?php
// src/Repository/ProductRepository.php (extrait)
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Product;
use Doctrine\ORM\Tools\Pagination\Paginator;
// ... class ProductRepository extends ServiceEntityRepository
/** @return Paginator<Product> */
public function paginateByCategory(?string $categorySlug, int $page, int $perPage): Paginator
{
$qb = $this->createQueryBuilder('p')
->addSelect('c')
->join('p.category', 'c')
->orderBy('p.id', 'DESC')
->setFirstResult(($page - 1) * $perPage)
->setMaxResults($perPage);
if (null !== $categorySlug) {
$qb->andWhere('c.slug = :slug')->setParameter('slug', $categorySlug);
}
return new Paginator($qb->getQuery(), fetchJoinCollection: true);
}Il manque encore, pour être à parité : le contrôleur mobile avec ses propres groupes, la documentation OpenAPI et un format d'erreur homogène.
Le verdict honnête : la version API Platform est plus courte et cohérente par construction. La version contrôleur est plus longue, mais chaque ligne est lisible par n'importe quel développeur Symfony, et un dump() au bon endroit suffit à comprendre ce qui se passe. Vous payez soit en apprentissage, soit en volume de code — sauf à prendre la troisième voie décrite plus bas.

Le code que vous n'écrivez pas existe quand même. Il est juste ailleurs.
Un détail qui surprend : avec #[MapRequestPayload], un échec de validation renvoie 422 par défaut, alors que #[MapQueryString] renvoie 404. Les deux se règlent via validationFailedStatusCode, mais l'écart de défaut se voit en production avant de se voir en relecture.

Aucune boîte noire, aucun point d'extension : tout est du code que vous avez écrit.
Les critères de décision#
Durée de vie du projet. Un outil interne prévu pour dix-huit mois n'amortit pas une courbe d'apprentissage ; une plateforme prévue pour cinq ans, si.
Nombre de ressources. Le critère le plus fiable, parce qu'il est mesurable dès le cadrage : trois endpoints écrits à la main sont cohérents, soixante ne le sont jamais.
Qui consomme l'API. Un front maison tolère une API imparfaite : les deux évoluent ensemble, dans le même dépôt. Un partenaire externe, non : il lui faut un contrat stable.
Besoin d'une documentation OpenAPI. Le point où API Platform est le plus difficile à battre : dérivée des mêmes métadonnées que le code, la documentation ne peut pas diverger. Écrite à part, elle diverge toujours ; la seule question est en combien de mois.
Divergence entre modèle et contrat. Si la ressource ressemble à l'entité, API Platform sur l'entité est confortable. Si le contrat agrège trois entités, renomme la moitié des champs et masque le reste, il faut des DTO — et une bonne part du gain initial disparaît. Jamais API Platform branché sur l'entité « en attendant ».
Compétences de l'équipe de reprise. Pas celle qui écrit : celle qui reprendra. Un développeur Symfony sans expérience d'API Platform est opérationnel sur des contrôleurs en une journée, sur une base API Platform non triviale en deux semaines.
Contraintes de performance. Les deux s'optimisent, mais pas au même endroit : avec des contrôleurs vous écrivez la requête ; avec API Platform vous configurez jointures et extensions de requête (Performance). Aucun chiffre général n'a de sens : la différence se mesure sur votre schéma.
| Contexte | Recommandation | Pourquoi |
|---|---|---|
| Moins de 5 ressources, front maison, équipe non formée | Contrôleurs | La courbe d'apprentissage ne s'amortit pas |
| Plus de 15 ressources CRUD proches des entités | API Platform | Le code répétitif devient le coût dominant |
| Consommateur tiers ou partenaire | API Platform | Contrat et documentation dérivés du code |
| Contrat très éloigné du modèle métier | API Platform + DTO, ou contrôleurs | Le raccourci « exposer l'entité » se paie au premier changement |
| Équipe de reprise inconnue | Contrôleurs, ou API Platform documenté en interne | La lisibilité prime sur la concision |
| Projet mixte CRUD + métier complexe | Hybride | Voir la section suivante |

Trois questions suffisent à écarter la moitié des débats d'architecture.
Là où API Platform coûte cher#
La courbe d'apprentissage est réelle et mal répartie. Les premières heures sont euphoriques : une API complète en vingt minutes. Le mur arrive au premier besoin non standard, et il est vertical : comprendre l'articulation entre providers et processors demande un vrai investissement.
Le débogage de la sérialisation. « Pourquoi ce champ n'apparaît pas ? » est la question la plus posée sur API Platform. La réponse est presque toujours un groupe manquant, mais le chemin pour y arriver passe par des métadonnées invisibles dans le code.
Le moment où l'on écrit autant de code custom que sans. Sur un endpoint métier complexe — provider, processor, DTO d'entrée, DTO de sortie, opération sur mesure — on atteint le volume d'un contrôleur classique, en devant en plus connaître le framework.
Le couplage à la montée de version. Illustration datée de cette semaine : en API Platform 5.0, une erreur de dénormalisation sur une propriété portant une contrainte du Validator renvoie 422 avec une charge ConstraintViolation, là où la 4.x renvoyait 400 avec hydra:Error. Les propriétés sans contrainte renvoient toujours 400. Aucun drapeau de configuration : on surcharge le service api_platform.state.denormalization_violation_factory. Si vos clients testent le code HTTP, cette montée de version change le contrat public.
Les performances sur les graphes profonds. Sérialiser un objet qui en tire cinquante autres coûte cher, et le problème se voit tard, une fois les données de production en place.
Là où le contrôleur classique coûte cher#
Le code répétitif, multiplié par le nombre d'endpoints. Pagination, filtrage, sérialisation, erreurs : quarante lignes par endpoint sans valeur métier. Sur soixante endpoints, c'est un module entier à maintenir.
La documentation qui diverge. Écrite à la main, elle est juste le jour de sa rédaction. Six mois plus tard, un champ a été ajouté sans mise à jour du fichier OpenAPI, et le partenaire intègre contre une documentation fausse.
L'incohérence entre endpoints écrits à six mois d'intervalle. Le premier pagine avec page/perPage, le second avec offset/limit ; le premier renvoie {"items": []}, le second un tableau nu. Personne n'a tort, et le consommateur porte la complexité.
La pagination et le filtrage réinventés. Chaque développeur réécrit la même logique, un peu différemment. La cinquième version a un bug sur la dernière page.
La gestion des erreurs jamais homogène. Un endpoint renvoie {"error": "..."}, un autre {"message": "..."}, un troisième une page HTML parce que l'exception n'a pas été interceptée.
Aucun de ces coûts n'est visible au sprint 1. Tous le sont au sprint 20.
La troisième voie, souvent la bonne#
Dans la majorité des projets que nous reprenons, la bonne réponse n'est pas un camp mais une frontière : API Platform sur les ressources CRUD proches du modèle, contrôleurs dédiés sur les endpoints métier. La cohabitation ne demande aucune configuration : ce sont deux systèmes de routage qui coexistent. Ce qu'il faut, c'est une règle explicite écrite dans le README, sinon la frontière devient poreuse en six mois.
La règle que nous appliquons :
- API Platform si l'endpoint se résume à lire ou écrire une ressource, même avec des filtres et des groupes.
- Contrôleur dédié si l'endpoint exprime une action métier :
POST /orders/{id}/valider, qui vérifie un stock, débite un paiement, envoie un e-mail et renvoie un résumé de traitement. Ce n'est pas une écriture de ressource, c'est une commande.
Bon test : si vous n'arrivez pas à nommer l'endpoint avec un substantif et un verbe HTTP, il ne relève pas du CRUD.

La frontière ne passe pas entre deux technologies, mais entre deux natures d'endpoint.
Deux points d'attention : le format d'erreur doit être unifié à la main, sinon les deux moitiés de l'API ne répondent pas pareil en cas d'échec ; et les contrôleurs dédiés n'apparaissent pas dans l'OpenAPI généré, il faut les y déclarer (documentation).
Et si je me trompe ?#
La bonne question n'est pas « quel choix est le meilleur » mais « quel choix est le plus réversible ».
Des contrôleurs vers API Platform. Confortable : entités, logique métier, repositories et tests fonctionnels sont conservés, vous ne réécrivez que la couche d'exposition. Deux à quatre jours pour une dizaine de ressources simples, davantage si les URL doivent rester identiques — et elles le doivent, si des clients existent.
D'API Platform vers des contrôleurs. Plus coûteux, pour une raison contre-intuitive : vous devez d'abord découvrir votre propre contrat. Ce que l'API renvoie exactement n'est écrit nulle part — c'est le produit des groupes de sérialisation, des formats activés et des conventions du framework. Il faut donc le figer dans des tests avant de réécrire, sous peine de casser des clients en silence.
Conclusion pratique : les contrôleurs sont le choix le plus réversible, API Platform le plus rentable si les critères ci-dessus penchent nettement de ce côté. Dans le doute, et seulement dans le doute : contrôleurs.
Un moyen peu coûteux de réduire le risque dans les deux sens : des tests fonctionnels qui interrogent l'API par HTTP et vérifient les charges utiles. Ils survivent à la migration et constituent le contrat. En 5.0, ApiTestCase a été extraite dans un paquet dédié, api-platform/test.
La version courte pour décideur#
- API Platform fait gagner du temps sur les API à beaucoup de ressources simples, et fournit une documentation qui ne ment jamais. Il demande une équipe formée.
- Les contrôleurs ne demandent aucune compétence nouvelle et se reprennent par n'importe quel développeur Symfony. Ils coûtent plus cher à mesure que l'API grossit.
- Le risque d'API Platform est de dépendre d'un cadre que votre équipe ne maîtrise pas encore au premier besoin inhabituel.
- Le risque des contrôleurs est une API incohérente et mal documentée au bout de deux ans, invisible tant qu'aucun tiers ne l'utilise.
- Le choix le moins risqué est presque toujours l'architecture hybride, à condition d'écrire la règle de frontière au démarrage.
FAQ#
API Platform est-il adapté à une API publique ? Oui, c'est même le cas où il est le plus pertinent : une API publique a besoin d'un contrat stable, documenté et cohérent, ce qu'il produit par construction. Deux précautions : exposer des DTO plutôt que vos entités Doctrine, et traiter les montées de version majeures comme des changements de contrat, puisqu'elles peuvent modifier codes HTTP et formats d'erreur.
Peut-on utiliser API Platform et des contrôleurs dans le même projet ? Oui, sans configuration particulière : deux mécanismes de routage Symfony qui coexistent. C'est même l'architecture la plus fréquente sur les projets matures. La difficulté est organisationnelle, pas technique : sans règle écrite, la frontière se brouille en quelques mois.
API Platform ralentit-il l'application ? Il ajoute une couche de métadonnées et de sérialisation dont le coût dépend surtout de la profondeur du graphe exposé. Les cas problématiques viennent presque toujours de relations sérialisées sans jointure explicite, pas du framework. Voir Performance, et mesurez sur votre schéma avec le profileur.
Faut-il utiliser des DTO avec API Platform ? Pas toujours. Si la ressource exposée ressemble à l'entité, les groupes de sérialisation suffisent et les DTO n'ajoutent que de l'indirection. Dès que le contrat diverge — champs agrégés, renommés, calculés — les DTO deviennent la seule façon de faire évoluer le modèle sans casser l'API. La documentation dédiée décrit leur mise en œuvre.
Quelle alternative pour du GraphQL ? API Platform expose GraphQL à partir des mêmes ressources, sans schéma à écrire séparément (documentation). C'est l'argument le plus fort en sa faveur : l'écrire à la main suppose une bibliothèque tierce et un schéma à maintenir en parallèle du code.
Conclusion#
Il n'y a pas de bonne réponse hors contexte, mais il y a des mauvaises façons de choisir : prendre API Platform parce que c'est le standard sans former l'équipe, ou le refuser par méfiance de la magie puis réécrire soixante fois la même pagination.
Trois questions tranchent réellement : combien de ressources, qui les consomme, et à quel point le contrat s'éloigne du modèle. Si les réponses ne convergent pas, posez la frontière hybride et écrivez-la dans le README.
Si vous arbitrez ce choix, ou reprenez une base où la frontière s'est brouillée, un regard extérieur sur l'architecture coûte moins cher qu'une migration. Nous livrons des deux façons, avec assez de cicatrices des deux côtés pour n'avoir aucun avis de principe.
Pour prolonger : la sortie de Symfony 7.3 sur les évolutions récentes du framework, comment utiliser l'API de PrestaShop pour l'exercice vu côté client, et notre comparatif des API françaises de données légales pour des contrats publics bien et mal conçus.
Sources#
Toutes consultées le 21 septembre 2026.
- API Platform – State Providers — https://api-platform.com/docs/core/state-providers/
- API Platform – State Processors — https://api-platform.com/docs/core/state-processors/
- API Platform – Filters — https://api-platform.com/docs/core/filters/
- API Platform – Serialization — https://api-platform.com/docs/core/serialization/
- API Platform – Pagination — https://api-platform.com/docs/core/pagination/
- API Platform – Validation — https://api-platform.com/docs/core/validation/
- API Platform – OpenAPI — https://api-platform.com/docs/core/openapi/
- API Platform – Performance — https://api-platform.com/docs/core/performance/
- API Platform – GraphQL — https://api-platform.com/docs/core/graphql/
- API Platform – DTO — https://api-platform.com/docs/core/dto/
- API Platform – CHANGELOG v4.4.0 et v5.0.0 (dépréciation des filtres historiques, passage de 400 à 422 sur les erreurs de dénormalisation, extraction d'
ApiTestCase) — https://github.com/api-platform/core/blob/v5.0.0/CHANGELOG.md - Symfony – Controller — https://symfony.com/doc/current/controller.html
- Symfony – Serializer — https://symfony.com/doc/current/serializer.html
- Symfony – Validation — https://symfony.com/doc/current/validation.html
Signatures vérifiées dans le code source : ApiPlatform\State\ProviderInterface, ApiPlatform\State\ProcessorInterface, ApiPlatform\Metadata\Parameter et les filtres Doctrine ORM au tag v4.4.0 ; Symfony\Component\HttpKernel\Attribute\MapRequestPayload et MapQueryString sur une installation Symfony 8.1.7.







