Aller au contenu principal
Lance-toi : 40% de réduction sur toutes les formations jusqu'au 30 juin En savoir plus

Construire une API REST propre avec Symfony 7 et API Platform : guide pas à pas

API Platform transforme Symfony en machine à produire des APIs REST en quelques lignes de configuration. Ce guide vous montre comment construire une API complète, sécurisée et bien structurée.

Guides & tutoriels · ·
Adel LATIBI
Adel LATIBI

Le Briefing Dev - les ressources et actus de la semaine, droit dans ta boîte chaque vendredi gratuitement.

Vous avez un projet Symfony qui expose des données via une API maison. Au début, deux ou trois contrôleurs faisaient le travail. Aujourd'hui, vous avez douze endpoints, chacun avec sa propre logique de pagination, sa propre gestion d'erreur, sa propre façon de filtrer. Quand le front demande un nouveau champ, il faut toucher trois fichiers. La doc Swagger n'est plus à jour depuis six mois.

C'est le problème que résout API Platform. Pas un framework de plus à apprendre pour le plaisir, mais un outil qui élimine le code répétitif tout en gardant la main sur ce qui compte vraiment : sécurité, logique métier, règles d'exposition. Ce guide couvre API Platform 4.3, Symfony 7.3 et PHP 8.3 minimum. Il suppose que vous connaissez déjà Symfony. Si ce n'est pas le cas, commencez par la formation Symfony 7 initiation.

Le problème : une API REST en Symfony, ça finit toujours par déraper

Si vous avez déjà construit une API REST avec Symfony seul, vous connaissez le scénario. Un contrôleur pour la liste, un autre pour le détail, un troisième pour la création. Sérialisation à la main, validation avec le composant Validator, doc avec NelmioApiDoc, voter pour la sécurité. Tout ça pour une seule ressource. Multipliez par dix entités : plusieurs centaines de lignes dont la moitié est dupliquée. Ce mélange viole un principe de base, la séparation des préoccupations.

API Platform inverse l'approche. Au lieu d'écrire la plomberie et de glisser dedans la logique métier, vous décrivez vos ressources et le framework génère la plomberie. Ce qui reste à votre charge : les règles spécifiques à votre domaine.

Le principe : déclarer plutôt que coder

L'idée centrale tient en une phrase : une entité bien décrite suffit pour exposer une API REST complète. Vous ajoutez l'attribut #[ApiResource] sur une classe Doctrine, et vous obtenez les opérations CRUD avec pagination, filtres, validation, sérialisation, gestion d'erreurs et documentation OpenAPI.

Le travail change de nature. Vous ne codez plus chaque endpoint, vous configurez ce que chaque ressource expose, à qui, avec quelles règles. C'est de la déclaration. Cette approche s'aligne sur un principe d'architecture connu : avoir une source unique de vérité pour la définition d'une ressource. Le modèle décrit l'entité, sa persistance, son exposition et ses règles d'accès.

Mettre en place le projet en 2026

On part d'une base Symfony moderne avec PHP 8.3 minimum. L'idée : construire une petite API de gestion d'articles de blog. Cas suffisamment réaliste pour rencontrer les vrais problèmes (relations, droits par auteur, filtres) sans s'éparpiller.

composer create-project symfony/skeleton:^7.3 mon-api
cd mon-api

composer require api
composer require symfony/orm-pack
composer require lexik/jwt-authentication-bundle
composer require symfony/maker-bundle --dev

Le paquet api installe API Platform avec sa configuration par défaut. Une recette Flex copie les fichiers nécessaires. Au lancement du serveur, vous avez déjà accès à /api et /api/docs, sans avoir écrit une ligne. Configurez la base dans .env.local (jamais dans .env qui est versionné) :

DATABASE_URL="postgresql://user:password@127.0.0.1:5432/mon_api?serverVersion=16"

PostgreSQL est devenu le choix par défaut pour les nouveaux projets Symfony en 2026, en grande partie pour sa gestion native des types JSON et des recherches full-text. MySQL reste évidemment supporté.

Première ressource : une entité Article exposée

Voici une entité prête à être exposée. Lisez-la lentement, chaque attribut a un rôle précis :

<?php
// src/Entity/Article.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 Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Serializer\Attribute\Groups;

#[ORM\Entity]
#[ApiResource(
    operations: [
        new GetCollection(),
        new Get(),
        new Post(security: "is_granted('ROLE_USER')"),
        new Patch(security: "is_granted('ROLE_USER') and object.getAuthor() == user"),
        new Delete(security: "is_granted('ROLE_ADMIN')"),
    ],
    normalizationContext: ['groups' => ['article:read']],
    denormalizationContext: ['groups' => ['article:write']],
    paginationItemsPerPage: 20,
)]
class Article
{
    #[ORM\Id, ORM\GeneratedValue, ORM\Column]
    #[Groups(['article:read'])]
    private ?int $id = null;

    #[ORM\Column(length: 255)]
    #[Assert\NotBlank]
    #[Assert\Length(max: 255)]
    #[Groups(['article:read', 'article:write'])]
    private string $title;

    #[ORM\Column(type: 'text')]
    #[Assert\NotBlank]
    #[Groups(['article:read', 'article:write'])]
    private string $content;

    #[ORM\Column]
    #[Groups(['article:read'])]
    private \DateTimeImmutable $createdAt;

    #[ORM\ManyToOne]
    #[Groups(['article:read'])]
    private ?User $author = null;

    public function __construct()
    {
        $this->createdAt = new \DateTimeImmutable();
    }

    // Getters et setters classiques
}

Quelques points méritent une attention particulière. D'abord, le passage de Put à Patch. En 2026, la communauté API Platform recommande PATCH pour les modifications partielles, ce qui correspond à 95% des cas réels. PUT impose l'envoi de l'objet complet, ce qui pose des problèmes dès qu'une nouvelle propriété arrive.

Ensuite, l'import de Groups. Il vient de Symfony\Component\Serializer\Attribute, pas Annotation. Le namespace Annotation est déprécié depuis Symfony 7.1 et supprimé en Symfony 8. Beaucoup d'articles en ligne utilisent encore l'ancien import et provoquent des warnings silencieux.

Avec cette seule classe, vous obtenez un ensemble cohérent d'endpoints documentés : la liste paginée, le détail, la création réservée aux utilisateurs connectés, la modification réservée à l'auteur, la suppression réservée aux administrateurs. Le tout avec validation, sérialisation contrôlée par groupes et documentation OpenAPI à jour automatiquement.

Sécuriser avec JWT proprement

L'authentification par JWT reste le standard pour les APIs en 2026. Le bundle Lexik est mature, bien intégré à Symfony et compatible avec API Platform sans configuration spécifique. Démarrez par la génération des clés :

php bin/console lexik:jwt:generate-keypair

La configuration du firewall dans config/packages/security.yaml :

security:
    firewalls:
        login:
            pattern: ^/api/login
            stateless: true
            json_login:
                check_path: /api/login_check
                success_handler: lexik_jwt_authentication.handler.authentication_success
                failure_handler: lexik_jwt_authentication.handler.authentication_failure

        api:
            pattern: ^/api
            stateless: true
            jwt: ~

    access_control:
        - { path: ^/api/login, roles: PUBLIC_ACCESS }
        - { path: ^/api/docs, roles: PUBLIC_ACCESS }
        - { path: ^/api, roles: IS_AUTHENTICATED_FULLY }

Un point souvent oublié : la durée de vie du token. Par défaut, Lexik la fixe à une heure. Pour une API mobile, c'est trop court. Pour une API publique, c'est probablement trop long. Ajustez dans config/packages/lexik_jwt_authentication.yaml avec token_ttl: 3600 et envisagez un refresh token pour les sessions longues.

Filtres : le piège de la version 4.3

API Platform 4.3 a introduit un changement important sur les filtres Doctrine. Les filtres basés sur les paramètres (ExactFilter, PartialSearchFilter, IriFilter, UuidFilter) exigent désormais la propriété explicite, sinon ils lèvent une InvalidArgumentException. Voici l'usage moderne :

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

#[ApiResource(...)]
#[ApiFilter(SearchFilter::class, properties: [
    'title' => 'partial',
    'author.name' => 'exact',
])]
#[ApiFilter(OrderFilter::class, properties: ['createdAt', 'title'])]
class Article { ... }

Le résultat : votre endpoint accepte automatiquement /api/articles?title=symfony pour la recherche partielle, /api/articles?order[createdAt]=desc pour le tri et /api/articles?page=2 pour la pagination.

Pour les recherches plus complexes (fourchette de dates, présence dans une liste, recherche full-text), API Platform fournit DateFilter, RangeFilter et BooleanFilter. Au-delà, écrire un filtre custom prend une trentaine de lignes et reste lisible.

State Processors : la place de la logique métier

C'est probablement le concept le plus mal compris d'API Platform. Un State Processor intercepte le moment où le framework s'apprête à persister une ressource. Il vous donne la main pour ajouter de la logique avant ou après la persistance, sans avoir à réécrire le contrôleur.

Cas typique : assigner automatiquement l'auteur connecté lors de la création d'un article. Sans Processor, vous devriez exposer le champ auteur en écriture, ce qui ouvrirait une faille (un utilisateur pourrait créer un article au nom d'un autre).

<?php
// src/State/ArticleProcessor.php

namespace App\State;

use ApiPlatform\Doctrine\Common\State\PersistProcessor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\Post;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Article;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;

#[AsDecorator(decorates: PersistProcessor::class)]
class ArticleProcessor implements ProcessorInterface
{
    public function __construct(
        private ProcessorInterface $persistProcessor,
        private Security $security,
    ) {}

    public function process(
        mixed $data,
        Operation $operation,
        array $uriVariables = [],
        array $context = []
    ): mixed {
        if ($data instanceof Article && $operation instanceof Post) {
            $data->setAuthor($this->security->getUser());
        }

        return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
    }
}

L'attribut #[AsDecorator] remplace les anciens fichiers de configuration services.yaml. Plus simple, plus explicite, plus facile à retrouver. Le pattern décorateur est un excellent exercice pratique pour comprendre la composition plutôt que l'héritage appliquée à un cas réel.

DTOs : quand l'entité ne suffit plus

Très vite, exposer directement les entités Doctrine pose problème. Le client a besoin d'un format différent. Un champ calculé n'a aucun sens en base. Plusieurs entités doivent être combinées en une seule réponse. C'est le moment de passer aux DTOs (Data Transfer Objects).

API Platform supporte deux options : input et output dans #[ApiResource]. Le DTO d'entrée reçoit les données du client et est ensuite transformé en entité par un State Processor. Le DTO de sortie est construit à partir de l'entité par un State Provider, ce qui permet de structurer la réponse exactement comme le front en a besoin.

#[ApiResource(
    output: ArticleOutput::class,
    input: ArticleInput::class,
)]
class Article { ... }

C'est plus de code à écrire, mais c'est la seule façon propre de découpler le contrat d'API du modèle de persistance. Tant que votre projet est petit, exposer directement les entités est tolérable. Au-delà de quinze ressources environ, la dette accumulée par ce raccourci finit par coûter cher.

La documentation OpenAPI, vivante

API Platform génère une documentation OpenAPI 3.1 à partir de vos attributs. Disponible à /api/docs, elle inclut Swagger UI pour tester depuis le navigateur, Redoc pour une vue lecture, et un export JSON consommable par des outils tiers (génération de clients, tests automatisés).

L'avantage est énorme dans une équipe avec des développeurs front. Ils n'attendent plus une doc rédigée à la main qui finit obsolète. Ils ouvrent /api/docs et essayent. C'est une expérience particulièrement utile pour les équipes qui pratiquent le fullstack avec une stack du type décrite dans la formation Fullstack React + Symfony.

Pour enrichir la doc, vous pouvez ajouter des descriptions à vos opérations, des exemples de payloads et de réponses, le tout via les attributs PHP. La documentation devient une caractéristique du code, pas un livrable séparé.

Les pièges qui font perdre une journée

Les groupes de sérialisation oubliés sur les relations

Si vous exposez un Article qui contient un User auteur, et que vous oubliez de définir des groupes de lecture sur l'entité User, vous obtiendrez soit un IRI tout seul, soit une explosion de données (mot de passe haché compris dans certains cas). Toujours préciser #[Groups(['article:read'])] sur les propriétés des relations à exposer.

Les filtres qui silencieusement ne filtrent rien

Si votre SearchFilter ne semble rien faire, vérifiez le mode (exact, partial, start, end, word_start) et l'orthographe exacte du paramètre dans l'URL. Les filtres ignorent en silence les paramètres inconnus, ce qui peut faire perdre une heure à chercher pourquoi rien ne change.

Le CORS qui bloque tout en développement

API Platform ne configure pas le CORS par défaut. Si votre front React tourne sur localhost:5173 et votre API sur localhost:8000, vous verrez les requêtes échouer en silence. Installez nelmio/cors-bundle et configurez-le dans config/packages/nelmio_cors.yaml.

Les n+1 sur les collections

Une liste de cent articles avec leur auteur déclenche cent une requêtes si vous n'avez pas configuré l'extension EagerLoading. Activez-la et vérifiez avec le profiler. Sur des collections plus larges, regardez aussi le cache HTTP qu'API Platform supporte nativement via les en-têtes ETag et Vary.

En production : les vraies bonnes pratiques

Désactivez les formats que vous n'utilisez pas. Par défaut, API Platform expose JSON-LD, Hydra, JSON, HAL et plus. Si votre client n'utilise que JSON, gardez juste JSON. La doc devient plus claire et le code de routage plus rapide.

Versionnez votre API. Préfixez les routes par /api/v1/ dès le départ. Quand vous voudrez introduire des changements cassants, vous pourrez ouvrir /api/v2/ sans casser les clients existants.

Limitez explicitement les opérations exposées. Toujours déclarer le tableau operations plutôt que de laisser API Platform tout générer. Une ressource qui n'a pas besoin de DELETE ne devrait pas l'exposer, même protégé. Le principe est défensif : ce qui n'existe pas ne peut pas être attaqué.

Activez le rate limiting. Symfony 7 propose le composant RateLimiter qui s'intègre proprement avec les firewalls. Limitez les endpoints d'authentification pour ralentir les attaques par force brute.

Testez. API Platform fournit ApiTestCase qui simplifie l'écriture de tests fonctionnels d'API. Les assertions JSON-LD comme assertJsonContains et assertMatchesResourceItemJsonSchema sont précises et lisibles. Une API non testée est une API condamnée à régresser.

Pourquoi pas FastAPI ou Spring Boot ?

Question légitime. La réponse honnête : ça dépend de votre stack existante et de votre équipe.

Si votre back est déjà en Symfony, ou si votre équipe maîtrise PHP, API Platform est de loin le choix le plus productif. Vous capitalisez sur l'écosystème Symfony, sur Doctrine, sur la richesse des bundles existants.

Si vous démarrez un projet from scratch et que la perf brute prime (machine learning, traitement temps réel), FastAPI en Python mérite l'évaluation. Ce sont deux philosophies différentes : Symfony et API Platform misent sur la convention et la richesse, FastAPI mise sur la simplicité et la rapidité d'exécution.

Pour une équipe Java côté entreprise, Spring Boot reste la référence. Le critère de choix n'est presque jamais le framework lui-même : c'est ce que votre équipe maîtrise et ce que votre infrastructure existante exige.

Conclusion

API Platform en 2026 a atteint une maturité difficile à concurrencer pour qui veut exposer des données via une API REST en PHP. La courbe d'apprentissage existe, surtout autour des State Processors et de la sérialisation par groupes. Mais une fois ces concepts intégrés, la productivité dépasse largement celle d'une API maison. Le piège : penser qu'API Platform vous dispense de comprendre ce qui se passe. C'est l'inverse. La densité du framework récompense ceux qui prennent le temps de comprendre la sérialisation Symfony, le firewall et le système d'événements Doctrine.

FAQ

Faut-il forcément exposer les entités Doctrine ?

Non, et au-delà d'une certaine taille c'est même déconseillé. Les DTOs (input et output) permettent de découpler le contrat de votre API du modèle de persistance. Le coût supplémentaire en code se rentabilise dès que les entités évoluent à un rythme différent de l'API.

API Platform supporte-t-il GraphQL ?

Oui, nativement. Une fois le composant GraphQL installé, les mêmes ressources REST sont exposées en GraphQL sans configuration supplémentaire. C'est un argument de poids pour les projets qui hésitent entre les deux approches.

Peut-on utiliser API Platform sans Doctrine ?

Oui. Le système de State Providers et State Processors permet de connecter n'importe quelle source de données : MongoDB, Elasticsearch, une API tierce, un fichier. Doctrine est le défaut documenté, pas une obligation.

Comment gérer l'upload de fichiers ?

L'approche officielle utilise VichUploaderBundle combiné avec un endpoint multipart/form-data spécifique. Pour des cas plus modernes, une URL signée S3 directement depuis le client reste la solution la plus scalable.

Quelle est la différence entre normalizationContext et denormalizationContext ?

Le premier contrôle la sortie (la lecture, ce que vous renvoyez au client). Le second contrôle l'entrée (l'écriture, ce que vous acceptez du client). Les groupes associés permettent d'exposer une propriété en lecture seulement, en écriture seulement, ou les deux.

Faut-il versionner par URL ou par en-tête ?

Le versioning par URL (/api/v1/) reste le plus simple à implémenter et à débugger, surtout pour les APIs publiques. Le versioning par en-tête est plus pur sémantiquement mais complique le cache HTTP.

Vous voulez aller plus loin ?

La formation Symfony 7 couvre les fondations indispensables avant API Platform. Pour aller jusqu'à l'application complète avec front React, la formation Fullstack React + Symfony intègre tout le parcours backend, API et front dans un projet réaliste.

Vous êtes expert ?

Partagez votre expertise sur notre blog

Tutoriel, retour d'expérience, analyse - publiez un article invité et gagnez en visibilité.

Écrire pour nous