◀ Retour au blog
PHP / Symfony

Construire des APIs avec Symfony API Platform

Publié le 25 Mar 2024· 8 min de lecture
#Symfony#API Platform#REST

API Platform : le framework API pour Symfony

API Platform est le framework de référence pour construire des APIs modernes avec Symfony. Il génère automatiquement une API REST et GraphQL à partir de vos entités.

Concrètement, vous décrivez vos ressources avec des attributs PHP (quelles opérations sont exposées, qui peut y accéder, quels champs sont lisibles ou modifiables) et API Platform se charge du reste : routage, sérialisation, validation, pagination, filtres, négociation de contenu et documentation OpenAPI. Le temps gagné sur ce code répétitif peut être consacré aux règles métier. Cet article couvre la mise en place d'une ressource complète, puis les points qui font la différence en production : groupes de sérialisation, sécurité, logique métier personnalisée et tests.

Installation et configuration

composer require api
# Crée automatiquement la configuration API Platform

Grâce à Symfony Flex, l'alias api installe API Platform et sa recette : fichier config/packages/api_platform.yaml, routes sous le préfixe /api, et configuration CORS. Une fois le serveur lancé, /api affiche une documentation interactive (Swagger UI) générée à partir de vos ressources. La configuration globale permet de fixer des valeurs par défaut pour toutes les ressources :

# config/packages/api_platform.yaml
api_platform:
    title: 'Catalogue API'
    version: '1.0.0'
    formats:
        jsonld: ['application/ld+json']
        json: ['application/json']
    defaults:
        pagination_items_per_page: 20
        pagination_maximum_items_per_page: 100
        pagination_client_items_per_page: true

Avec pagination_client_items_per_page, le client peut choisir la taille de page via le paramètre itemsPerPage, mais jamais au-delà du maximum fixé. Sans ce plafond, une seule requête pourrait demander toute la table.

Créer une ressource API

Une ressource est une classe marquée avec #[ApiResource]. Il s'agit souvent d'une entité Doctrine, comme ici, même si ce n'est pas obligatoire. La liste operations déclare explicitement les opérations exposées : tout ce qui n'y figure pas n'existe pas dans l'API.

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;

#[ApiResource(
    operations: [
        new GetCollection(),
        new Get(),
        new Post(security: "is_granted('ROLE_ADMIN')"),
    ],
    paginationItemsPerPage: 20,
)]
#[ORM\Entity]
class Product
{
    #[ORM\Id, ORM\GeneratedValue, ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 255)]
    #[Assert\NotBlank]
    private string $name;

    #[ORM\Column(type: 'decimal', precision: 10, scale: 2)]
    private string $price;

    // Getters et setters...
}

Ce seul fichier produit GET /api/products (collection paginée), GET /api/products/{id} et POST /api/products, réservé aux administrateurs. Le prix est stocké en decimal et manipulé comme une chaîne en PHP : c'est volontaire, pour éviter les erreurs d'arrondi des nombres flottants sur des montants. Les contraintes de validation (#[Assert\NotBlank]) sont appliquées automatiquement lors des écritures : une donnée invalide produit une réponse 422 qui détaille chaque violation, champ par champ.

Filtres et recherche

Les filtres ajoutent des paramètres de requête aux collections, sans écrire une ligne de requête SQL :

use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
use ApiPlatform\Doctrine\Orm\Filter\RangeFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;

#[ApiFilter(SearchFilter::class, properties: ['name' => 'partial'])]
#[ApiFilter(RangeFilter::class, properties: ['price'])]
#[ApiFilter(OrderFilter::class, properties: ['name', 'price'])]
class Product
{
    // ...
}

Le client peut alors appeler /api/products?name=clavier&price[lt]=100&order[price]=asc. Chaque filtre apparaît aussi dans la documentation OpenAPI. Deux précautions : un filtre partial se traduit par un LIKE '%…%' qui ne profite pas d'un index B-tree classique et devient coûteux sur de grosses tables ; et n'exposez des filtres que sur les champs réellement utiles, chacun étant une surface de requête supplémentaire. Les versions récentes d'API Platform proposent aussi une approche par paramètres (QueryParameter) déclarés directement sur l'opération.

Serialization Groups

Par défaut, toutes les propriétés accessibles sont exposées en lecture comme en écriture. C'est rarement ce que l'on veut : l'identifiant ne doit pas être modifiable, et certains champs internes ne doivent jamais sortir. Les groupes de sérialisation séparent ce que l'API lit de ce qu'elle accepte :

use ApiPlatform\Metadata\ApiResource;
use Symfony\Component\Serializer\Attribute\Groups;

#[ApiResource(
    normalizationContext: ['groups' => ['product:read']],
    denormalizationContext: ['groups' => ['product:write']],
)]
class Product
{
    #[Groups(['product:read'])]
    private ?int $id = null;

    #[Groups(['product:read', 'product:write'])]
    private string $name;

    #[Groups(['product:read', 'product:write'])]
    private string $price;
}

Une propriété sans groupe est invisible pour l'API. C'est la protection la plus simple contre l'exposition accidentelle d'un champ ajouté plus tard à l'entité, comme un prix d'achat ou une note interne. Adoptez une convention de nommage (ressource:read, ressource:write) et tenez-vous-y.

Sécurité par opération

L'option security accepte une expression évaluée avant l'opération. Pour les opérations sur un élément, la variable object donne accès à la ressource concernée, ce qui permet des règles de propriété :

use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Patch;

#[ApiResource(
    operations: [
        new Patch(security: "is_granted('ROLE_ADMIN') or object.getOwner() == user"),
        new Delete(security: "is_granted('ROLE_ADMIN')"),
    ],
)]

Pour des règles plus riches, déléguez à un Voter Symfony avec is_granted('PRODUCT_EDIT', object) : la logique d'autorisation reste testable et réutilisable en dehors de l'API.

Logique métier : les State Processors

Par défaut, les écritures sont persistées directement par Doctrine. Pour ajouter un comportement (envoi d'un message, calcul, appel externe), API Platform utilise des State Providers pour la lecture et des State Processors pour l'écriture. Le schéma le plus courant consiste à décorer le processor Doctrine :

namespace App\State;

use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Product;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;

/** @implements ProcessorInterface<Product, Product> */
final class ProductProcessor implements ProcessorInterface
{
    public function __construct(
        #[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
        private ProcessorInterface $persistProcessor,
        private LoggerInterface $logger,
    ) {}

    public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): Product
    {
        $product = $this->persistProcessor->process($data, $operation, $uriVariables, $context);

        $this->logger->info('Produit enregistré', ['id' => $product->getId()]);

        return $product;
    }
}

On l'active ensuite sur l'opération concernée : new Post(processor: ProductProcessor::class). Le même mécanisme permet d'exposer des ressources qui ne sont pas des entités, comme un DTO alimenté par une API tierce, avec un provider et un processor dédiés. C'est souvent plus propre que d'exposer directement le modèle de base de données.

Tester l'API

API Platform fournit ApiTestCase, une classe de test basée sur le client HTTP de Symfony, avec des assertions adaptées aux réponses JSON :

use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;

final class ProductApiTest extends ApiTestCase
{
    public function testCollectionIsPublic(): void
    {
        static::createClient()->request('GET', '/api/products');

        $this->assertResponseIsSuccessful();
        $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8');
    }

    public function testUnknownProductReturns404(): void
    {
        static::createClient()->request('GET', '/api/products/999999');

        $this->assertResponseStatusCodeSame(404);
    }
}

Ajoutez au minimum un test par règle de sécurité : un utilisateur sans droits doit recevoir une erreur, et un administrateur doit réussir. C'est là que se cachent les régressions les plus graves.

Avantages

  • Documentation OpenAPI générée automatiquement
  • Support JSON-LD et Hydra
  • Pagination, filtres et tri intégrés
  • Validation automatique via les contraintes Symfony
  • Support GraphQL natif

Le support GraphQL demande l'installation d'un paquet supplémentaire, puis réutilise les mêmes ressources, groupes et règles de sécurité que l'API REST. La spécification OpenAPI peut être exportée avec php bin/console api:openapi:export pour générer des clients ou alimenter une CI de contrat.

Des plateformes comme CCM Benchmark utilisent API Platform pour exposer leurs services internes via des APIs standardisées.

Les pièges à éviter

  • Exposer l'entité telle quelle : sans groupes de sérialisation, chaque nouvelle propriété devient publique. Définissez toujours des groupes, ou passez par des DTO.
  • Oublier le N+1 : une collection qui sérialise des relations peut déclencher une requête par élément. Surveillez le profiler Symfony et ajoutez des jointures (via une extension Doctrine d'API Platform) si nécessaire.
  • Laisser toutes les opérations par défaut : sans liste operations, API Platform expose aussi PUT, PATCH et DELETE. Déclarez explicitement ce que vous voulez.
  • Coder la sécurité dans les contrôleurs : gardez-la dans les attributs security et les Voters, là où elle est visible et testable.

API Platform n'est pas le meilleur choix pour une API faite de quelques endpoints très spécifiques, sans lien avec un modèle de ressources : un contrôleur Symfony classique sera alors plus simple. Mais dès qu'il s'agit d'exposer un modèle métier en CRUD avec pagination, filtres, sécurité et documentation, c'est l'un des moyens les plus productifs de le faire en PHP.