Entretien éditorial. Choisissez API Platform pour une API Symfony centrée sur des ressources, avec opérations CRUD, validation, pagination, sécurité et contrat OpenAPI standardisés. Préférez une implémentation manuelle lorsque chaque endpoint orchestre un workflow métier atypique, impose un protocole spécifique ou exige un contrôle SQL très fin. GraphQL n’est pas l’option opposée : API Platform peut aussi l’exposer. Une architecte API détaille ici les arbitrages, puis les pièges observés sur les commandes, catalogues et intégrations partenaires.
Choisir API Platform ou développer les endpoints à la main
Dans quels projets API Platform apporte-t-il réellement un avantage ?
Il excelle lorsque le domaine peut être présenté sous forme de ressources stables : commandes, produits, factures, comptes ou tickets. On déclare les opérations autorisées, les règles de validation et les groupes de sérialisation ; le framework construit alors une grande partie de la couche HTTP. Cela évite de répéter contrôleurs, réponses d’erreur, pagination et documentation.
Le choix n’est toutefois pas « API Platform contre REST ou GraphQL ». API Platform produit nativement une API REST et peut activer GraphQL. La vraie question est le niveau de convention acceptable.
| Situation | Choix conseillé | Motif |
|---|---|---|
| Catalogue avec lecture, filtres et pagination | API Platform REST | Ressources et opérations prévisibles |
| Application mobile aux écrans très différents | API Platform GraphQL ou REST ciblé | Sélection flexible des données |
| Validation d’une commande avec plusieurs services métier | Opération API Platform personnalisée | Contrat standard, logique explicite |
| Import bancaire propriétaire ou webhook complexe | Contrôleur Symfony dédié | Flux éloigné d’un CRUD |
| Reporting avec SQL fortement optimisé | Provider personnalisé ou endpoint manuel | Maîtrise de la requête et du résultat |
Pour une commande, je peux conserver une ressource API Platform pour GET /orders/{id}, mais confier POST /orders/{id}/confirm à un processor qui vérifie le stock, réserve le paiement et publie un message. À l’inverse, envelopper artificiellement un import CSV dans une entité Doctrine crée souvent plus de complexité qu’un contrôleur dédié.
L’important est de ne pas exposer automatiquement tout le modèle de persistance. Les DTO d’entrée et de sortie préservent le domaine. Cette séparation rejoint les principes présentés dans le guide complet de Symfony et reste valable si l’équipe hésite encore entre Laravel et Symfony.
Générer OpenAPI sans abandonner la maîtrise du contrat
La documentation automatique est-elle suffisamment fiable pour des clients externes ?
Oui, si les métadonnées sont traitées comme du code produit. La documentation Core d’API Platform confirme que la spécification OpenAPI est générée depuis les ressources et leurs métadonnées PHP, historiquement via annotations et aujourd’hui couramment via attributs. Il n’est donc pas nécessaire d’écrire toute la spécification à la main.
<?php
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use Symfony\Component\Serializer\Annotation\Groups;
#[ApiResource(
operations: [new Get(), new GetCollection()],
normalizationContext: ['groups' => ['order:read']]
)]
final class Order
{
#[Groups(['order:read'])]
public int $id;
#[Groups(['order:read'])]
public string $status;
}
Cette déclaration fournit les chemins, schémas et réponses attendues dans OpenAPI. Mais l’automatisation ne devine ni le vocabulaire métier ni les engagements pris envers les consommateurs. J’ajoute systématiquement des descriptions, des exemples représentatifs et les réponses d’erreur utiles. Pour un partenaire logistique, status doit notamment énumérer les valeurs publiques, pas reproduire aveuglément une constante interne susceptible de changer.
Mon contrôle avant publication comporte trois étapes :
- exporter le document OpenAPI dans la CI ;
- détecter les changements incompatibles : champ supprimé, type modifié, paramètre devenu obligatoire ;
- exécuter des tests de contrat avec un client représentatif.
Une IA peut proposer descriptions et exemples, mais elle ne décide pas si un champ est contractuel. Les techniques de prompt engineering appliqué à PHP et Symfony aident à produire un premier brouillon, jamais à remplacer la revue. Une interface Swagger élégante ne garantit ni la stabilité sémantique ni la bonne gestion des erreurs.
Paginer, filtrer et trier sur de gros volumes
Comment empêcher un endpoint de catalogue ou de commandes de devenir coûteux ?
La documentation officielle de la pagination API Platform décrit deux stratégies natives : pagination par pages et pagination par curseur. La première convient à un back-office où l’utilisateur veut atteindre une page donnée. Le curseur est préférable pour un flux continuellement alimenté, car les insertions entre deux appels perturbent moins la navigation.
<?php
use ApiPlatform\Metadata\ApiResource;
#[ApiResource(
paginationItemsPerPage: 30,
paginationClientItemsPerPage: true,
paginationMaximumItemsPerPage: 100,
paginationViaCursor: [
['field' => 'id', 'direction' => 'DESC']
]
)]
final class Order
{
public int $id;
}
La limite maximale est essentielle : accepter itemsPerPage=100000 transforme une commodité client en risque opérationnel. Pour un historique de commandes, un curseur fondé sur une clé unique et ordonnée évite aussi les ambiguïtés. Si l’ordre métier repose sur createdAt, j’ajoute généralement id comme critère de départage dans la conception de la requête.
Le filtrage exige la même discipline :
- n’exposer que les propriétés nécessaires aux consommateurs ;
- indexer les colonnes réellement utilisées dans
WHEREetORDER BY; - éviter les recherches génériques insensibles à la casse sur toutes les colonnes ;
- refuser les combinaisons de filtres entraînant des jointures incontrôlées ;
- préférer un filtre métier dédié pour les recherches complexes.
Par exemple, status=paid&customerId=42 peut s’appuyer sur des index adaptés. En revanche, un filtre libre traversant client, adresse, lignes et produit mérite un moteur de recherche ou un provider spécialisé. Pour une exportation complète, je ne désactive pas la pagination : je crée un traitement asynchrone produisant un fichier. Le client reçoit un identifiant de tâche plutôt qu’une réponse HTTP immobilisée pendant l’extraction.
Sécuriser avec JWT, scopes, voters et limitation de débit
JWT suffit-il pour sécuriser une API Platform moderne ?
Non. JWT transporte une identité et des claims ; il ne remplace ni l’autorisation métier, ni la révocation, ni la limitation de débit. La documentation Symfony Security fournit authentification, rôles et voters. API Platform applique ensuite ces décisions à la ressource ou à l’opération. LexikJWTAuthenticationBundle est un choix fréquent pour les jetons JWT, tandis qu’OAuth2 devient pertinent lorsque plusieurs applications, utilisateurs ou scopes doivent être délégués.
<?php
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\Post;
#[ApiResource(operations: [
new Get(security: "is_granted('ORDER_VIEW', object)"),
new Post(security: "is_granted('ROLE_ORDER_WRITER')")
])]
final class Order
{
}
Le voter ORDER_VIEW peut vérifier que l’utilisateur appartient au même compte que la commande. C’est plus sûr qu’un simple ROLE_USER, trop large dans une application multitenant. Pour une application partenaire, je traduis aussi les scopes OAuth en permissions explicites : orders:read n’autorise ni remboursement ni modification d’adresse.
Une défense cohérente combine au minimum :
- authentification du client ou de l’utilisateur ;
- autorisation par opération et, si nécessaire, par objet ;
- validation stricte des entrées ;
- rate limiting par clé, compte ou adresse selon le scénario ;
- journalisation des refus et actions sensibles.
Le composant Rate Limiter de Symfony permet de définir ces politiques. Une route de connexion peut avoir une règle différente d’un endpoint de consultation. Attention également aux proxys : utiliser aveuglément l’adresse transmise dans un en-tête permet de contourner une limite si les proxys de confiance sont mal configurés. Un développement complet doit donc relier la sécurité des API Symfony aux tests d’autorisation et à l’observabilité.
Versionner sans casser les applications clientes
Faut-il préférer /v1/, un en-tête Accept ou aucune version visible ?
Aucune stratégie n’est universellement supérieure. Le préfixe /v1/ est lisible, facile à router et pratique pour des partenaires externes. La négociation par Accept garde des URL stables, mais demande une meilleure maîtrise des clients, caches et outils de diagnostic. API Platform permet d’organiser les deux approches par sa configuration et ses opérations.
Je préfère souvent des DTO séparés pour empêcher une évolution du domaine de modifier silencieusement le contrat :
<?php
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
#[ApiResource(
uriTemplate: '/v1/orders/{id}',
operations: [new Get()],
provider: OrderV1Provider::class
)]
final class OrderV1Output
{
public int $id;
public string $status;
}
Une version 2 peut alors remplacer status par un objet détaillé sans altérer la version 1. Le provider traduit le même modèle métier vers deux représentations. Autre cas : ajouter un champ facultatif est généralement compatible, tandis que renommer une propriété ou rendre un paramètre obligatoire impose une transition.
Mon processus est concret : annoncer la dépréciation, mesurer quels clients utilisent encore l’ancienne version, publier une date de retrait adaptée aux engagements contractuels, puis tester les deux contrats pendant la migration. Les en-têtes de dépréciation et une documentation claire facilitent cette période, mais ne remplacent pas le dialogue avec les consommateurs.
Enfin, je lie l’export OpenAPI et les tests de compatibilité au pipeline décrit dans ce guide CI/CD Symfony. Une rupture détectée avant fusion coûte moins cher qu’une application mobile impossible à mettre à jour immédiatement.
Éviter les N+1 et la sérialisation excessive en production
Quels problèmes de performance reviennent le plus souvent ?
Le premier est le N+1 Doctrine. La documentation Performance d’API Platform documente ce risque et recommande notamment un eager loading ciblé ou des dataloaders. Prenons une collection de commandes : une requête charge les commandes, puis l’accès à lines peut déclencher une requête supplémentaire pour chacune.
Pour un écran qui exige réellement les lignes, une requête ciblée peut les joindre :
<?php
final class OrderRepository
{
public function findRecentWithLines(): array
{
return $this->createQueryBuilder('o')
->leftJoin('o.lines', 'line')
->addSelect('line')
->orderBy('o.id', 'DESC')
->setMaxResults(30)
->getQuery()
->getResult();
}
}
Cette requête doit alimenter un provider dédié ; elle ne doit pas devenir le chargement par défaut de toutes les commandes. Sur l’écran récapitulatif, le nombre de lignes suffit parfois. Charger chaque produit, sa catégorie et ses médias ne ferait que déplacer le problème vers une jointure gigantesque.
Le second piège est la sérialisation. Des groupes trop larges parcourent des graphes d’objets, calculent des accesseurs coûteux et gonflent la réponse. Je contrôle donc séparément :
- les données récupérées par Doctrine ;
- les champs exposés par les groupes ;
- la profondeur des relations ;
- les calculs déclenchés pendant la normalisation ;
- la taille finale et la durée observée par endpoint.
Un DTO de liste réduit à id, status, total et customerName est souvent préférable à l’entité complète. Pour le détail, un autre DTO peut inclure les lignes. Ce découpage rend également le contrat OpenAPI plus lisible.
Enfin, les standards PSR-7 et PSR-15 du PHP-FIG définissent des contrats communs pour messages et middlewares HTTP. Symfony utilise son propre modèle HttpFoundation, avec des ponts d’interopérabilité lorsque nécessaire. Cette frontière est utile pour intégrer un middleware ou un composant externe, mais elle ne corrige pas une mauvaise requête Doctrine. En production, je mesure d’abord SQL, mémoire, temps de normalisation et volume transféré ; j’optimise ensuite l’étape réellement responsable.