Aller au contenu principal
Retour au blog

API Platform 4.1 + Symfony 7.2 : provider/processor de A à Z

Flavien Métivier18 mars 202510 min

API Platform 4.1 est sorti le 28 février 2025, et si tu as travaillé avec les versions antérieures à 4.0, le choc est réel. Là où tu collais #[ApiResource] directement sur une entité Doctrine en espérant que la magie opère, tu dois désormais penser en trois couches distinctes : la ressource (DTO de présentation), le provider (source de données) et le processor (persistance et logique métier). Cette séparation n'est pas une contrainte — c'est une philosophie d'architecture qui rend tes APIs testables, évolutives et indépendantes de ta couche de persistance. Ce guide construit une API CRUD complète, paginée et sécurisée par JWT sur Symfony 7.2 et PHP 8.4, de zéro jusqu'à la documentation OpenAPI auto-générée.

Ce qui bascule vraiment avec la 4.1

API Platform 4.0 avait posé les fondations en rendant providers et processors explicites. La 4.1 ferme les dernières portes de sortie : la résolution automatique des providers Doctrine devient opt-in, l'autowiring suit strictement le conteneur Symfony, et les décorateurs s'enchaînent proprement via la chaîne de responsabilité. En clair, le framework ne devine plus ta source de données — c'est toi qui le déclares explicitement, et c'est mieux ainsi.

  • La classe annotée #[ApiResource] est un DTO pur — jamais une entité Doctrine directe
  • ProviderInterface::provide() remplace les anciens DataProviderInterface::getCollection() et getItem()
  • ProcessorInterface::process() remplace DataPersisterInterface::persist() et remove()
  • Le provider et le processor se déclarent par attribut (provider:, processor:) directement sur la ressource
  • La sécurité par opération s'exprime dans #[ApiResource] et est évaluée par l'ExpressionLanguage de Symfony

Installation et configuration de base

On part d'un projet Symfony 7.2 vierge. PHP 8.4 est requis. Toutes les dépendances tiennent en une seule commande Composer, puis la configuration minimale d'API Platform se résume à une dizaine de lignes YAML.

composer create-project symfony/skeleton blog-api
cd blog-api
composer require api-platform/core:^4.1 \
    doctrine/doctrine-bundle \
    doctrine/orm \
    symfony/validator \
    symfony/security-bundle \
    lexik/jwt-authentication-bundle \
    nelmio/cors-bundle
# config/packages/api_platform.yaml
api_platform:
    title: 'Blog API'
    version: '1.0.0'
    formats:
        jsonld: ['application/ld+json']
        json:   ['application/json']
    defaults:
        stateless: true
        cache_headers:
            vary: ['Content-Type', 'Authorization']
    swagger:
        versions: [3.1]

La ressource DTO, découplée de l'entité

L'entité Doctrine est le modèle de persistance : elle décrit comment la donnée est stockée. La ressource API est un DTO de présentation : elle décrit ce que le client HTTP voit et peut envoyer. Ces deux classes n'ont aucun héritage commun, et c'est voulu. Ce découplage te permet de modifier ton schéma de base de données sans changer le contrat HTTP, et de versionner les deux indépendamment.

<?php
// src/Entity/Article.php
namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
#[ORM\Table(name: 'articles')]
class Article
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    public ?int $id = null;

    #[ORM\Column(length: 255)]
    public string $title = '';

    #[ORM\Column(type: 'text')]
    public string $content = '';

    #[ORM\Column]
    public \DateTimeImmutable $createdAt;

    #[ORM\Column(length: 20)]
    public string $status = 'draft';

    public function __construct()
    {
        $this->createdAt = new \DateTimeImmutable();
    }
}
<?php
// src/ApiResource/ArticleResource.php
namespace App\ApiResource;

use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\State\ArticleProvider;
use App\State\ArticleProcessor;
use Symfony\Component\Validator\Constraints as Assert;

#[ApiResource(
    shortName: 'Article',
    operations: [
        new GetCollection(),
        new Get(),
        new Post(
            security: "is_granted('ROLE_EDITOR')",
        ),
        new Patch(
            security: "is_granted('ROLE_EDITOR') and object.authorId == user.getId()",
        ),
        new Delete(
            security: "is_granted('ROLE_ADMIN')",
        ),
    ],
    provider: ArticleProvider::class,
    processor: ArticleProcessor::class,
)]
class ArticleResource
{
    #[ApiProperty(readable: false, writable: false, identifier: true)]
    public ?int $id = null;

    #[Assert\NotBlank]
    #[Assert\Length(min: 5, max: 255)]
    #[ApiProperty(description: 'Titre de l\'article', example: 'Mon premier article API Platform 4.1')]
    public string $title = '';

    #[Assert\NotBlank]
    public string $content = '';

    #[ApiProperty(readable: true, writable: false, description: 'Statut : draft ou published')]
    public string $status = 'draft';

    #[ApiProperty(readable: true, writable: false)]
    public ?\DateTimeImmutable $createdAt = null;

    public ?int $authorId = null;
}

Le StateProvider : récupérer la donnée

Le provider est l'unique point d'entrée pour toutes les opérations de lecture. Il reçoit l'opération en cours (GetCollection, Get…) et les variables d'URI (id, slug…). API Platform ne présume rien de la source : Doctrine, Redis, une API tierce — c'est entièrement ton choix. Le provider retourne soit un objet unique pour Get, soit une collection itérable pour GetCollection.

<?php
// src/State/ArticleProvider.php
namespace App\State;

use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\ApiResource\ArticleResource;
use App\Entity\Article;
use App\Repository\ArticleRepository;

final class ArticleProvider implements ProviderInterface
{
    public function __construct(
        private readonly ArticleRepository $repository,
    ) {}

    public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
    {
        if ($operation instanceof GetCollection) {
            $page    = max(1, (int) ($context['filters']['page'] ?? 1));
            $perPage = 10;

            $entities = $this->repository->findBy(
                [],
                ['createdAt' => 'DESC'],
                $perPage,
                ($page - 1) * $perPage
            );

            return array_map(fn(Article $e) => $this->toResource($e), $entities);
        }

        $entity = $this->repository->find($uriVariables['id'] ?? 0);

        return $entity ? $this->toResource($entity) : null;
    }

    private function toResource(Article $entity): ArticleResource
    {
        $resource            = new ArticleResource();
        $resource->id        = $entity->id;
        $resource->title     = $entity->title;
        $resource->content   = $entity->content;
        $resource->status    = $entity->status;
        $resource->createdAt = $entity->createdAt;

        return $resource;
    }
}

Besoin d'un expert Symfony ?

Réserver un appel

Le StateProcessor : persister et piloter les transitions métier

Le processor prend en charge les opérations d'écriture (Post, Patch, Delete). Il reçoit la ressource déjà validée par le composant Validator de Symfony — inutile de re-valider manuellement. C'est ici que tu places ta logique métier : transition de statut, publication d'un événement de domaine, envoi de webhook. En séparant provider et processor, tu peux tester chacun isolément sans monter de kernel complet.

<?php
// src/State/ArticleProcessor.php
namespace App\State;

use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\Post;
use ApiPlatform\State\ProcessorInterface;
use App\ApiResource\ArticleResource;
use App\Entity\Article;
use App\Repository\ArticleRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;

final class ArticleProcessor implements ProcessorInterface
{
    public function __construct(
        private readonly EntityManagerInterface $em,
        private readonly ArticleRepository $repository,
        private readonly Security $security,
    ) {}

    public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ?ArticleResource
    {
        assert($data instanceof ArticleResource);

        if ($operation instanceof Delete) {
            $entity = $this->repository->find($uriVariables['id']);
            if ($entity) {
                $this->em->remove($entity);
                $this->em->flush();
            }
            return null;
        }

        $entity = ($operation instanceof Post)
            ? new Article()
            : ($this->repository->find($uriVariables['id']) ?? new Article());

        $entity->title   = $data->title;
        $entity->content = $data->content;

        // Transition métier : publication réservée aux admins
        if ($data->status === 'published' && $this->security->isGranted('ROLE_ADMIN')) {
            $entity->status = 'published';
        }

        $this->em->persist($entity);
        $this->em->flush();

        // On renvoie le DTO mis à jour depuis la source de vérité
        $data->id        = $entity->id;
        $data->createdAt = $entity->createdAt;
        $data->status    = $entity->status;

        return $data;
    }
}

Sécurité JWT : configuration en quelques lignes

On utilise lexik/jwt-authentication-bundle 3.x, compatible Symfony 7.2. La génération de la paire de clés RSA est automatisée via la console Symfony. Le pare-feu stateless est déclaré en YAML, et la sécurité fine par opération — qui peut créer, qui peut publier, qui peut supprimer — est déjà exprimée dans les attributs du DTO. Rien à ajouter ailleurs.

# Génération de la paire de clés RSA pour JWT
php bin/console lexik:jwt:generate-keypair
# Les clés sont créées dans config/jwt/ (à exclure du git, sauf la clé publique)
# config/packages/security.yaml
security:
    password_hashers:
        App\Entity\User:
            algorithm: bcrypt

    providers:
        app_user_provider:
            entity:
                class: App\Entity\User
                property: email

    firewalls:
        login:
            pattern: ^/api/auth
            stateless: true
            json_login:
                check_path: /api/auth
                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/auth, roles: PUBLIC_ACCESS }
        - { path: ^/api/docs, roles: PUBLIC_ACCESS }   # Documentation OpenAPI publique
        - { path: ^/api,      roles: IS_AUTHENTICATED_FULLY }

Pour obtenir un token, envoie un POST /api/auth avec {"username": "...", "password": "..."} en JSON. Le bundle retourne un JWT signé avec ta clé privée RSA, valide 3 600 secondes par défaut. Toutes les routes /api exigeront ce token en header Authorization: Bearer <token>. Les expressions is_granted('ROLE_EDITOR') dans les attributs des opérations sont évaluées après décodage du token — sans une seule ligne de PHP supplémentaire.

Pagination et documentation OpenAPI auto-générée

API Platform 4.1 génère automatiquement une documentation OpenAPI 3.1 accessible sur /api/docs. #[ApiFilter] ajoute des paramètres de recherche et de tri directement dans le schéma Swagger, et #[ApiProperty] enrichit les exemples et descriptions de chaque champ. La pagination s'active par configuration — aucun code supplémentaire n'est requis dans le provider si tu retournes un tableau ou un objet compatible PaginatorInterface.

<?php
// src/ApiResource/ArticleResource.php — ajout des filtres et de la pagination
namespace App\ApiResource;

use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\State\ArticleProcessor;
use App\State\ArticleProvider;
use Symfony\Component\Validator\Constraints as Assert;

#[ApiResource(
    shortName: 'Article',
    paginationEnabled: true,
    paginationItemsPerPage: 10,
    paginationMaximumItemsPerPage: 50,
    operations: [
        new GetCollection(),
        new Get(),
        new Post(security: "is_granted('ROLE_EDITOR')"),
        new Patch(security: "is_granted('ROLE_EDITOR') and object.authorId == user.getId()"),
        new Delete(security: "is_granted('ROLE_ADMIN')"),
    ],
    provider: ArticleProvider::class,
    processor: ArticleProcessor::class,
)]
#[ApiFilter(SearchFilter::class, properties: ['title' => 'partial', 'status' => 'exact'])]
#[ApiFilter(OrderFilter::class, properties: ['createdAt', 'title'], arguments: ['orderParameterName' => 'order'])]
class ArticleResource
{
    #[ApiProperty(readable: false, writable: false, identifier: true)]
    public ?int $id = null;

    #[Assert\NotBlank]
    #[Assert\Length(min: 5, max: 255)]
    #[ApiProperty(description: 'Titre de l\'article', example: 'Mon premier article API Platform 4.1')]
    public string $title = '';

    #[Assert\NotBlank]
    public string $content = '';

    #[ApiProperty(readable: true, writable: false, description: 'Statut : draft ou published')]
    public string $status = 'draft';

    #[ApiProperty(readable: true, writable: false)]
    public ?\DateTimeImmutable $createdAt = null;

    public ?int $authorId = null;
}
// Paramètres disponibles dans /api/docs et utilisables directement :
// GET /api/articles?title=guide&status=published&order[createdAt]=desc&page=2

Ce qu'il reste à explorer

L'architecture provider/processor d'API Platform 4.1 peut paraître verbeuse au premier abord, mais elle offre ce que la magie implicite des versions antérieures ne permettait pas : un code testable couche par couche, une séparation franche entre lecture et écriture, et une indépendance totale vis-à-vis de Doctrine. Les prochaines étapes naturelles sont les décorateurs de provider pour ajouter de la mise en cache transparente, les opérations personnalisées avec #[Post(uriTemplate: '/articles/{id}/publish')] pour modéliser les transitions métier comme des actions HTTP dédiées, et les tests unitaires de chaque state handler — sans monter de kernel, avec un simple new ArticleProvider($repositoryMock).

Cet article vous a plu ? Partagez-le !

Besoin d'un expert Symfony ?

20 ans d'expérience sur l'écosystème PHP/Symfony.