API Platform 3 — API REST en minutes

API Platform 3 génère automatiquement une API REST complète (GET/POST/PUT/PATCH/DELETE) + documentation OpenAPI + client GraphQL à partir d'un seul attribut sur votre entité.
composer require api
# Installe api-platform/core, serializer, doctrine…
Auto-CRUD

GET/POST/PUT/PATCH/DELETE générés automatiquement

OpenAPI

Swagger UI intégrée — /api/docs

Hydra/JSON-LD

Hypermedia par défaut, JSON:API en option

Extensible

StateProcessors, StateProviders, Voters

#[ApiResource] — déclaration

<?php
namespace App\Entity;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Delete;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;

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

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

    #[ORM\Column(type: 'text', nullable: true)]
    #[Groups(['article:read', 'article:write'])]
    private ?string $body = null;

    #[ORM\Column(type: 'boolean')]
    #[Groups(['article:read'])]
    private bool $published = false;
}

Opérations personnalisées

<?php
use ApiPlatform\Metadata\Post as ApiPost;

// Opération custom : publier un article
#[ApiPost(
    uriTemplate:  '/articles/{id}/publish',
    name:         'article_publish',
    input:        false,
    output:       Article::class,
    processor:    PublishArticleProcessor::class,
    security:     "is_granted('ROLE_EDITOR')",
    description:  'Publier un article',
)]
class Article { /* … */ }

Routes générées (exemple)

GET    /api/articles          → liste paginée
POST   /api/articles          → créer un article
GET    /api/articles/{id}     → détail
PUT    /api/articles/{id}     → remplacer
PATCH  /api/articles/{id}     → modifier (partiel)
DELETE /api/articles/{id}     → supprimer
POST   /api/articles/{id}/publish  → opération custom

Groupes de sérialisation

<?php
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Serializer\Annotation\SerializedName;

class Article
{
    #[Groups(['article:list', 'article:read'])]
    private ?int $id = null;

    // SerializedName — renommer la clé JSON
    #[Groups(['article:list', 'article:read', 'article:write'])]
    #[SerializedName('headline')]
    private string $title = '';

    // Relation imbriquée — exposer seulement certains champs
    #[Groups(['article:read'])]
    private ?User $author = null;
}

class User
{
    #[Groups(['article:read', 'user:read'])]
    private ?int $id = null;

    #[Groups(['article:read', 'user:read'])]
    private string $name = '';

    // email non exposé dans le contexte article:read
    #[Groups(['user:read'])]
    private string $email = '';
}

Propriétés calculées (ApiProperty)

<?php
use ApiPlatform\Metadata\ApiProperty;

class Article
{
    #[ApiProperty(readable: true, writable: false)]
    #[Groups(['article:read'])]
    public function getExcerpt(): string
    {
        return mb_substr($this->body ?? '', 0, 200) . '…';
    }
}

Filtres

<?php
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
use ApiPlatform\Doctrine\Orm\Filter\RangeFilter;
use ApiPlatform\Metadata\ApiFilter;

#[ApiResource]
#[ApiFilter(SearchFilter::class, properties: [
    'title'           => 'partial',  // LIKE %title%
    'author.email'    => 'exact',
    'category.name'   => 'ipartial', // insensible à la casse
])]
#[ApiFilter(OrderFilter::class, properties: ['createdAt', 'title'])]
#[ApiFilter(BooleanFilter::class, properties: ['published'])]
#[ApiFilter(DateFilter::class, properties: ['createdAt'])]
class Article { /* … */ }
# Exemples de requêtes avec filtres
GET /api/articles?title=symfony
GET /api/articles?published=true
GET /api/articles?order[createdAt]=desc
GET /api/articles?createdAt[after]=2026-01-01
GET /api/articles?author.email=user@example.com

StateProcessor & StateProvider

StateProcessor — modifier les données avant persistance

<?php
namespace App\State;

use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Article;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;

class ArticleProcessor implements ProcessorInterface
{
    public function __construct(
        private readonly ProcessorInterface $innerProcessor,
        private readonly SluggerInterface   $slugger,
    ) {}

    public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
    {
        if ($data instanceof Article && empty($data->getSlug())) {
            $data->setSlug($this->slugger->slug($data->getTitle())->lower()->toString());
        }

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

StateProvider — source de données custom

<?php
namespace App\State;

use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Dto\ArticleStatsDto;

class ArticleStatsProvider implements ProviderInterface
{
    public function __construct(private readonly ArticleRepository $repo) {}

    public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
    {
        return new ArticleStatsDto(
            total:     $this->repo->count([]),
            published: $this->repo->countPublished(),
        );
    }
}

Documentation OpenAPI / Swagger

<?php
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\OpenApi\Model;

#[ApiResource(
    openapi: new Model\Operation(
        summary:     'Créer un article',
        description: 'Crée un nouvel article et retourne la ressource créée.',
        tags:        ['Articles'],
        requestBody: new Model\RequestBody(
            description: 'Données de l\'article',
            content: new \ArrayObject([
                'application/json' => [
                    'schema' => [
                        'type'       => 'object',
                        'properties' => [
                            'title' => ['type' => 'string', 'example' => 'Mon article Symfony'],
                            'body'  => ['type' => 'string'],
                        ],
                    ],
                ],
            ]),
        ),
    )
)]
class Article { /* … */ }
Swagger UI disponible sur /api/docs — interface interactive pour tester tous les endpoints. JSON à /api/docs.json, YAML à /api/docs.yaml.
▶ Mini-projet SF07 🧠 QCM SF07 Module 08 →