Aller au contenu principal

Débuter avec API Platform

Créer une ressource proprement : Entity, DTO, Mapper et State

Introduction

Dans ce tutoriel, nous allons découvrir comment créer une ressource API avec API Platform en suivant les bonnes pratiques d'architecture. Nous verrons comment séparer les préoccupations en utilisant :

  • Entity Doctrine : pour la persistance des données
  • DTO (Data Transfer Object) : pour l'exposition de l'API
  • MicroMapper : pour la transformation entre Entity et DTO
  • State Provider/Processor : pour la logique métier

Ce guide s’adresse à toute personne qui a déjà entendu parler d’API Platform, mais qui souhaite faire ses premiers pas de manière structurée, avec une approche plus professionnelle que les exemples CRUD basiques.

Prérequis

  • Symfony 6.x ou 7.x
  • API Platform 3.x
  • Doctrine ORM
  • PHP 8.1+

Architecture

Pour commencer, installez API Platform dans votre projet Symfony :

cd mon-projet
composer require api

Une fois l'installation terminée, vous verrez apparaître un nouveau dossier src/ApiResource/. C'est là que nous placerons nos DTOs pour exposer nos ressources API.

L'architecture que nous allons mettre en place sépare clairement :

  • src/Entity/ : les entités Doctrine (base de données)
  • src/ApiResource/ : les DTOs exposés via l'API
  • src/Mapper/ : les mappers pour transformer Entity ↔ DTO
  • src/State/ : les providers et processors pour la logique métier

Création de l'Entity Doctrine

Ce que nous allons créer

Nous allons créer une entité Region simple avec deux champs métier :

  • code : un code court (8 caractères max)
  • name : le nom de la région (255 caractères max)

Mais au lieu de partir directement avec notre entité, nous allons d'abord créer une classe de base (BaseEntity) qui contiendra les champs communs à toutes nos entités.

Pourquoi une BaseEntity ?

Bonne pratique

Dans un projet professionnel, on évite la répétition de code. Plutôt que de définir $id et $isActif dans chaque entité, on les centralise dans une classe mère. Cela facilite la maintenance et garantit la cohérence.

Notre BaseEntity contiendra :

  • id : un UUID (plus sécurisé et scalable qu'un auto-increment)
  • isActif : un booléen pour la gestion du soft-delete

Étape 1 : Créer l'entité Region

Commençons par générer l'entité avec Symfony CLI :

php bin/console make:entity Region

Création de l'entité Region

Ajoutez les deux champs :

  • code (string, 8)
  • name (string, 255)
info

Les deux champs peuvent être nullable pour plus de flexibilité lors de la création.

Vous obtenez une classe Region dans src/Entity/ avec un $id auto-généré par Doctrine.

Étape 2 : Créer la BaseEntity

Créez maintenant le fichier src/Entity/BaseEntity.php :

src/Entity/BaseEntity.php
<?php

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\IdGenerator\UuidGenerator;

#[ORM\MappedSuperclass]
abstract class BaseEntity
{
#[ORM\Id]
#[ORM\Column(type: "string", length: 36, unique: true)]
#[ORM\GeneratedValue(strategy: "CUSTOM")]
#[ORM\CustomIdGenerator(class: UuidGenerator::class)]
protected ?string $id = null;

#[ORM\Column(type: "boolean")]
protected bool $isActif = true;

public function getId(): ?string
{
return $this->id;
}

public function getIsActif(): bool
{
return $this->isActif;
}

public function setIsActif(bool $isActif): static
{
$this->isActif = $isActif;

return $this;
}
}
Attention

Notez l'attribut #[ORM\MappedSuperclass] : il indique que cette classe ne sera pas une table en base, mais servira uniquement de modèle pour les entités filles.

Points importants :

  • L'id est stocké en string (UUID au format texte)
  • UuidGenerator génère automatiquement un UUID v4 à la création
  • Pas besoin de gérer manuellement l'ID !

Étape 3 : Faire hériter Region de BaseEntity

Modifiez maintenant votre entité Region pour qu'elle hérite de BaseEntity :

src/Entity/Region.php
<?php

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;
use App\Repository\RegionRepository;

#[ORM\Entity(repositoryClass: RegionRepository::class)]
class Region extends BaseEntity
{
#[ORM\Column(length: 8, nullable: true)]
private ?string $code = null;

#[ORM\Column(length: 255, nullable: true)]
private ?string $name = null;

public function getCode(): ?string
{
return $this->code;
}

public function setCode(?string $code): static
{
$this->code = $code;

return $this;
}

public function getName(): ?string
{
return $this->name;
}

public function setName(?string $name): static
{
$this->name = $name;

return $this;
}
}
Nettoyage

Supprimez la propriété $id générée automatiquement par make:entity, puisqu'elle est maintenant héritée de BaseEntity avec notre bel UUID. L'avantage ? C'est générique : besoin de changer le type d'ID demain, ou d'ajouter un champ commun (createdAt, updatedAt) ? Un seul endroit à modifier.

Étape 4 : Créer la migration

php bin/console make:migration
php bin/console doctrine:migrations:migrate

Votre table region est maintenant créée avec les champs id (UUID), code, name, et is_actif.

Votre entité Doctrine est prête !

Création du DTO ApiResource

API Platform en 2 minutes

Avant de continuer, clarifions ce qu'est API Platform :

C'est un framework Symfony qui transforme vos classes PHP en API REST automatiquement. Vous ajoutez un attribut #[ApiResource] sur une classe, et boom : endpoints CRUD, documentation OpenAPI, pagination, filtres... tout est généré.

Le principe de base :

  1. Vous créez une classe (Entity ou DTO)
  2. Vous ajoutez #[ApiResource] dessus
  3. API Platform génère automatiquement les routes (/api/resources)

Mais attention : exposer directement vos entités Doctrine via #[ApiResource], c'est pratique pour un prototype, mais dangereux en production :

  • Vous exposez potentiellement des champs sensibles (mots de passe, tokens...)
  • Votre structure de BDD devient votre contrat API (difficile à faire évoluer)
  • Pas de contrôle sur ce qui entre/sort

La solution ? Les DTOs (Data Transfer Objects) : des classes dédiées à l'API, séparées de la base de données.

Architecture propre
  • Entity : structure BDD (attributs Doctrine #[ORM\...])
  • DTO : structure API (attribut API Platform #[ApiResource])
  • Mapper : convertit Entity ↔ DTO
  • State : gère la logique (récupérer, créer, modifier...)

Le DTO dans tout ça ?

Maintenant que notre entité existe, créons le DTO qui sera exposé via l'API. L'idée ? Séparer la base de données de l'API. Si demain vous changez votre schéma en base, votre contrat API reste stable.

Pourquoi un DTO ?

Séparation des responsabilités
  • Entity = structure de la BDD (Doctrine)
  • DTO = structure de l'API (API Platform)
  • Vous pouvez cacher des champs sensibles, renommer des propriétés, ou calculer des données sans toucher à votre base.

Étape 1 : Créer BaseDto

Comme pour les entités, créons d'abord une classe mère pour nos DTOs :

src/ApiResource/BaseDto.php
<?php

namespace App\ApiResource;

use ApiPlatform\Metadata\ApiProperty;

abstract class BaseDto
{
#[ApiProperty(readable: false, writable: false, identifier: true)]
public ?string $id = null;

#[ApiProperty(readable: true, writable: false)]
public ?bool $isActif = true;
}

Décryptage des attributs ApiProperty :

  • readable: false : le champ n'apparaît pas en lecture (GET)
  • writable: false : le champ ne peut pas être modifié via l'API (POST/PATCH)
  • identifier: true : ce champ est l'identifiant de la ressource (utilisé dans les URLs /api/regions/{id})
Pourquoi string pour $id ?

On utilise string dans le DTO pour plusieurs raisons pragmatiques :

  • Simplicité : pas de gestion de conversion/validation du type Uuid côté API
  • Flexibilité : si demain vous changez le format d'ID (ULID, nanoid...), le DTO reste stable
  • Indépendance : le DTO n'est pas couplé aux détails d'implémentation de l'Entity
  • Le Mapper s'occupera de la conversion entre le string du DTO et le type réel de l'Entity (UUID, int, etc.)

Pour isActif, on le rend lisible mais non modifiable : utile pour afficher l'état, mais la logique de désactivation se fera via un endpoint dédié (ou un soft-delete).

Étape 2 : Créer RegionDto

Créez maintenant le fichier src/ApiResource/RegionDto.php :

src/ApiResource/RegionDto.php
<?php

namespace App\ApiResource;

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 ApiPlatform\Doctrine\Orm\State\Options;
use App\Entity\Region;

#[ApiResource(
shortName: 'Region',
operations: [
new Get(),
new GetCollection(),
new Post(),
new Patch(),
new Delete(openapi: false, security: 'is_granted("ROLE_ADMIN")'),
],
paginationItemsPerPage: 10,
stateOptions: new Options(entityClass: Region::class),
)]
class RegionDto extends BaseDto
{
public ?string $code = null;
public ?string $name = null;
}

Décryptage des attributs

shortName: 'Region'
Le nom de la ressource dans l'API (/api/regions).

operations
Les opérations CRUD disponibles :

  • Get : récupérer une région par ID
  • GetCollection : lister toutes les régions
  • Post : créer une région
  • Patch : modifier partiellement une région
  • Delete : supprimer (réservé aux admins, caché de la doc OpenAPI)
Delete sécurisé

Notez openapi: false pour cacher l'endpoint de la doc, et security: 'is_granted("ROLE_ADMIN")' pour le restreindre.

paginationItemsPerPage: 10
Limite à 10 résultats par page sur GetCollection.

stateOptions: new Options(entityClass: Region::class)
API Platform sait maintenant que ce DTO correspond à l'entité Region. C'est crucial pour les States génériques qu'on verra au prochain chapitre.

Tester l'API

Vous pouvez déjà accéder à la documentation de votre API :

http://localhost:8000/api

Vous verrez vos endpoints /api/regions apparaître !

Documentation API Platform générée automatiquement

Pas encore fonctionnel

Pour l'instant, l'API ne fonctionne pas vraiment. Il manque le Mapper (pour transformer Entity ↔ DTO) et les States (pour gérer la logique). On y vient !

States génériques (Provider & Processor)

Le problème

Vous avez votre Entity (structure BDD) et votre DTO (structure API). Mais qui fait le lien ? Qui dit à API Platform :

  • Comment récupérer les données depuis la base ?
  • Comment transformer l'Entity en DTO (et vice-versa) ?
  • Comment enregistrer les modifications ?

La magie d'API Platform

Pour un développeur Symfony classique, créer une API REST, c'est :

  1. Créer des Controllers (RegionController)
  2. Gérer les routes (#[Route('/api/regions', methods: ['GET'])])
  3. Récupérer les données via le Repository
  4. Transformer les objets en JSON manuellement
  5. Gérer la pagination, les filtres, la validation...
  6. Répéter tout ça pour chaque ressource

API Platform change le jeu :

  • Plus de controllers manuels
  • Plus de routes à définir
  • Plus de serialization manuelle
  • Pagination, filtres, validation : inclus

Mais comment ? Grâce aux States (Provider & Processor) : des classes qui gèrent automatiquement la logique CRUD.

Provider vs Processor

Provider (Fournisseur)
→ Récupère les données (GET, GET Collection)
→ Lit depuis la base de données
→ Transforme Entity → DTO

Processor (Processeur)
→ Modifie les données (POST, PATCH, DELETE)
→ Écrit dans la base de données
→ Transforme DTO → Entity

States génériques : le code qui se réutilise

Comme notre BaseEntity, on va créer des States génériques qu'on utilisera pour toutes nos ressources. Plus besoin de réécrire la logique pour chaque entité !

Installation de MicroMapper

Avant de coder nos States, on va installer une petite lib qui va nous faciliter la vie : MicroMapper de SymfonyCasts, créé par Ryan Weaver.

composer require symfonycasts/micro-mapper

Pourquoi utiliser une lib pour mapper Entity ↔ DTO ?

Vous pourriez faire le mapping manuellement :

$dto = new RegionDto();
$dto->id = $entity->getId();
$dto->code = $entity->getCode();
$dto->name = $entity->getName();
// ... et répéter ça pour chaque propriété, dans chaque ressource

MicroMapper automatise tout ça :

  • Détecte automatiquement les propriétés communes
  • Supporte les transformations personnalisées (pour les cas complexes)
  • Léger et simple (pas un monstre comme AutoMapper)
  • Fait par Ryan Weaver (le papa de Symfony UX, MakerBundle...)
Philosophie

MicroMapper ne fait pas tout magiquement. Il gère les cas simples automatiquement et vous laisse contrôler les cas complexes. Exactement ce qu'on veut !

Étape 1 : Créer le Provider générique

Créez le fichier src/State/EntityToDtoStateProvider.php :

src/State/EntityToDtoStateProvider.php
<?php

namespace App\State;

use ApiPlatform\Doctrine\Orm\Paginator;
use ApiPlatform\Doctrine\Orm\State\CollectionProvider;
use ApiPlatform\Doctrine\Orm\State\ItemProvider;
use ApiPlatform\Metadata\CollectionOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\Pagination\TraversablePaginator;
use ApiPlatform\State\ProviderInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfonycasts\MicroMapper\MicroMapperInterface;

class EntityToDtoStateProvider implements ProviderInterface
{
public function __construct(
#[Autowire(service: CollectionProvider::class)] private ProviderInterface $collectionProvider,
#[Autowire(service: ItemProvider::class)] private ProviderInterface $itemProvider,
private MicroMapperInterface $microMapper
)
{
}

public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
{
$resourceClass = $operation->getClass();

// Si c'est une collection (GET /api/regions)
if ($operation instanceof CollectionOperationInterface) {
$entities = $this->collectionProvider->provide($operation, $uriVariables, $context);
assert($entities instanceof Paginator);

$dtos = [];
foreach ($entities as $entity) {
$dtos[] = $this->mapEntityToDto($entity, $resourceClass);
}

return new TraversablePaginator(
new \ArrayIterator($dtos),
$entities->getCurrentPage(),
$entities->getItemsPerPage(),
$entities->getTotalItems()
);
}

// Si c'est un item (GET /api/regions/{id})
$entity = $this->itemProvider->provide($operation, $uriVariables, $context);
if (!$entity) {
return null;
}

return $this->mapEntityToDto($entity, $resourceClass);
}

private function mapEntityToDto(object $entity, string $resourceClass): object
{
return $this->microMapper->map($entity, $resourceClass);
}
}

Ce que fait ce Provider :

  1. Récupère l'entité depuis Doctrine (via CollectionProvider ou ItemProvider)
  2. Transforme Entity → DTO avec le MicroMapper
  3. Gère la pagination automatiquement pour les collections

Étape 2 : Créer le Processor générique

Créez le fichier src/State/EntityClassDtoStateProcessor.php :

src/State/EntityClassDtoStateProcessor.php
<?php

namespace App\State;

use ApiPlatform\Doctrine\Common\State\PersistProcessor;
use ApiPlatform\Doctrine\Common\State\RemoveProcessor;
use ApiPlatform\Doctrine\Orm\State\Options;
use ApiPlatform\Metadata\DeleteOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfonycasts\MicroMapper\MicroMapperInterface;

class EntityClassDtoStateProcessor implements ProcessorInterface
{
public function __construct(
#[Autowire(service: PersistProcessor::class)] private ProcessorInterface $persistProcessor,
#[Autowire(service: RemoveProcessor::class)] private ProcessorInterface $removeProcessor,
private MicroMapperInterface $microMapper
)
{
}

public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = [])
{
$stateOptions = $operation->getStateOptions();
assert($stateOptions instanceof Options);

$entityClass = $stateOptions->getEntityClass();

// Transforme DTO → Entity
$entity = $this->mapDtoToEntity($data, $entityClass);

// Si c'est une suppression (DELETE)
if ($operation instanceof DeleteOperationInterface) {
$this->removeProcessor->process($entity, $operation, $uriVariables, $context);
return null;
}

// Sinon, enregistre l'entité (POST, PATCH)
$this->persistProcessor->process($entity, $operation, $uriVariables, $context);

// Met à jour l'ID du DTO (utile après un POST)
$data->id = $entity->getId();

return $data;
}

private function mapDtoToEntity(object $dto, string $entityClass): object
{
return $this->microMapper->map($dto, $entityClass);
}
}

Ce que fait ce Processor :

  1. Transforme DTO → Entity avec le MicroMapper
  2. Persiste l'entité en base (via PersistProcessor)
  3. Supprime l'entité si c'est un DELETE (via RemoveProcessor)
  4. Retourne le DTO avec l'ID mis à jour (important pour les POST)
Le pouvoir du générique

Ces deux classes fonctionneront pour toutes vos ressources (Region, User, Product...). Vous n'aurez plus à réécrire cette logique. C'est ça, le clean code !

Étape 3 : Connecter les States au DTO

Maintenant, modifions notre RegionDto pour utiliser ces States :

src/ApiResource/RegionDto.php
<?php

namespace App\ApiResource;

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 ApiPlatform\Doctrine\Orm\State\Options;
use App\Entity\Region;
use App\State\EntityClassDtoStateProcessor;
use App\State\EntityToDtoStateProvider;

#[ApiResource(
shortName: 'Region',
operations: [
new Get(),
new GetCollection(),
new Post(),
new Patch(),
new Delete(openapi: false, security: 'is_granted("ROLE_ADMIN")'),
],
paginationItemsPerPage: 10,
provider: EntityToDtoStateProvider::class,
processor: EntityClassDtoStateProcessor::class,
stateOptions: new Options(entityClass: Region::class),
)]
class RegionDto extends BaseDto
{
public ?string $code = null;
public ?string $name = null;
}

Les lignes magiques :

  • provider: EntityToDtoStateProvider::class → gère les GET
  • processor: EntityClassDtoStateProcessor::class → gère les POST, PATCH, DELETE

Presque fini ! Il reste une dernière étape : créer les Mappers pour que MicroMapper sache comment transformer nos objets.

Implémentation du MicroMapper

Où en sommes-nous ?

Faisons le point sur ce qu'on a construit :

  • Entity Region : structure en base de données
  • DTO RegionDto : structure exposée via l'API
  • Provider : récupère les données et devrait transformer Entity → DTO
  • Processor : reçoit les données et devrait transformer DTO → Entity

Le problème ? Nos States appellent $this->microMapper->map($entity, RegionDto::class), mais MicroMapper ne sait pas encore comment faire la transformation. Il a besoin qu'on lui dise.

Comment MicroMapper fonctionne

MicroMapper utilise des MapperInterface : des petites classes qui définissent la transformation entre deux objets.

Le principe :

  1. Vous créez un Mapper pour chaque sens de transformation
  2. RegionEntityToDto : transforme RegionRegionDto
  3. RegionDtoToEntity : transforme RegionDtoRegion
  4. MicroMapper les auto-découvre et les utilise automatiquement
Boucle complète

Une fois les Mappers créés, la boucle sera bouclée :

  1. Requête API → DTO reçu
  2. Processor → Mapper transforme DTO → Entity
  3. Entity → sauvegardée en BDD
  4. Provider → récupère Entity
  5. Mapper transforme Entity → DTO
  6. DTO → renvoyé en JSON

Étape 1 : Créer le Mapper Entity → DTO

Créez le fichier src/Mapper/RegionEntityToDtoMapper.php :

src/Mapper/RegionEntityToDtoMapper.php
<?php

namespace App\Mapper;

use App\ApiResource\RegionDto;
use App\Entity\Region;
use Symfonycasts\MicroMapper\AsMapper;
use Symfonycasts\MicroMapper\MapperInterface;

#[AsMapper(from: Region::class, to: RegionDto::class)]
class RegionEntityToDtoMapper implements MapperInterface
{
public function load(object $from, string $toClass, array $context): object
{
$entity = $from;
assert($entity instanceof Region);

$dto = new RegionDto();
$dto->id = $entity->getId();

return $dto;
}

public function populate(object $from, object $to, array $context): object
{
$entity = $from;
$dto = $to;

assert($entity instanceof Region);
assert($dto instanceof RegionDto);

$dto->code = $entity->getCode();
$dto->name = $entity->getName();

return $dto;
}
}

Ce que fait ce Mapper :

  • load() : crée l'objet de destination (le DTO) avec l'ID
  • populate() : remplit les propriétés du DTO avec les valeurs de l'Entity
Pourquoi deux méthodes ?

load() est appelé en premier (pour créer l'objet), puis populate() remplit les données. Cette séparation permet à MicroMapper de gérer des relations complexes et d'éviter les duplications.

Étape 2 : Créer le Mapper DTO → Entity

Créez le fichier src/Mapper/RegionDtoToEntityMapper.php :

src/Mapper/RegionDtoToEntityMapper.php
<?php

namespace App\Mapper;

use App\ApiResource\RegionDto;
use App\Entity\Region;
use Doctrine\ORM\EntityManagerInterface;
use Symfonycasts\MicroMapper\AsMapper;
use Symfonycasts\MicroMapper\MapperInterface;

#[AsMapper(from: RegionDto::class, to: Region::class)]
class RegionDtoToEntityMapper implements MapperInterface
{
public function __construct(private RegionRepository $regionRepository)
{
}

public function load(object $from, string $toClass, array $context): object
{
$dto = $from;
assert($dto instanceof RegionDto);

return $dto->id ? $this->regionRepository->find($dto->id) : new Region();
}

public function populate(object $from, object $to, array $context): object
{
$dto = $from;
$entity = $to;

assert($dto instanceof RegionDto);
assert($entity instanceof Region);

$entity->setCode($dto->code);
$entity->setName($dto->name);

return $entity;
}
}

Ce que fait ce Mapper :

  • load() : récupère l'entité existante (si ID fourni) ou en crée une nouvelle
  • populate() : met à jour les propriétés de l'Entity avec les valeurs du DTO
PATCH vs POST

Notez la logique dans load() : si un ID est fourni, on récupère l'entité existante (requête PATCH). Sinon, on en crée une nouvelle (requête POST). C'est comme ça qu'on gère les modifications partielles !

Étape 3 : Tester l'API

Votre API est maintenant 100% fonctionnelle !

Testez avec Swagger UI (http://localhost:8000/api) :

Pour tester un endpoint :

  1. Cliquez sur l'opération (ex: GET /api/regions)
  2. Cliquez sur "Try it out"
  3. Cliquez sur "Execute"

Tester l&#39;API avec Swagger UI

Fixtures de test

Pour avoir des données à tester, pensez à peupler votre base avec des fixtures (Doctrine Fixtures ou Foundry). Les exemples ci-dessous utilisent des régions de Guinée.

Résultat d'un GET /api/regions :

Résultat GET Collection

{
"@context": "/api/contexts/Region",
"@id": "/api/regions",
"@type": "Collection",
"totalItems": 4,
"member": [
{
"@id": "/api/regions/1f0ddc27-480d-65e8-9eae-e31b2cd94dd7",
"@type": "Region",
"code": "CON",
"name": "Conakry",
"isActif": true
},
{
"@id": "/api/regions/1f0ddc27-4810-69a0-b3be-e31b2cd94dd7",
"@type": "Region",
"code": "LAB",
"name": "Labé",
"isActif": true
},
{
"@id": "/api/regions/1f0ddc27-4812-6eee-a3f6-e31b2cd94dd7",
"@type": "Region",
"code": "KAN",
"name": "Kankan",
"isActif": true
},
{
"@id": "/api/regions/1f0ddc27-4815-62b6-830e-e31b2cd94dd7",
"@type": "Region",
"code": "NZR",
"name": "Nzérékoré",
"isActif": true
}
]
}

Opérations disponibles :

1. POST /api/regions (créer une région)

Envoyez ce JSON dans le body :

{
"code": "IDF",
"name": "Île-de-France"
}

Réponse (Status 201 Created) :

{
"@context": "/api/contexts/Region",
"@id": "/api/regions/1f0ddc38-882b-6e98-b90b-4db90a55b24f",
"@type": "Region",
"code": "IDF",
"name": "Île-de-France",
"isActif": true
}

2. GET /api/regions (lister toutes les régions)

Voir l'exemple de réponse ci-dessus.

3. GET /api/regions/{id} (récupérer une région)

Exemple : GET /api/regions/1f0ddc27-480d-65e8-9eae-e31b2cd94dd7

Réponse :

{
"@context": "/api/contexts/Region",
"@id": "/api/regions/1f0ddc27-480d-65e8-9eae-e31b2cd94dd7",
"@type": "Region",
"code": "CON",
"name": "Conakry",
"isActif": true
}

4. PATCH /api/regions/{id} (modifier partiellement une région)

Exemple : PATCH /api/regions/1f0ddc27-480d-65e8-9eae-e31b2cd94dd7

Sélectionnez une région, cliquez sur "Try it out", modifiez le JSON (seuls les champs à modifier) :

{
"name": "Conakry - Capitale"
}

Réponse (Status 200 OK) :

{
"@context": "/api/contexts/Region",
"@id": "/api/regions/1f0ddc27-480d-65e8-9eae-e31b2cd94dd7",
"@type": "Region",
"code": "CON",
"name": "Conakry - Capitale",
"isActif": true
}

5. DELETE /api/regions/{id} (supprimer - admin seulement)

Sécurisé

Cette opération est cachée de la doc OpenAPI (openapi: false) et nécessite le rôle ADMIN. Elle ne sera pas visible dans Swagger UI par défaut.

Architecture complète

Voilà ce qui se passe maintenant pour un GET /api/regions :

  1. API Platform reçoit la requête
  2. Provider récupère les Region depuis Doctrine
  3. Mapper transforme chaque RegionRegionDto
  4. API Platform sérialise les DTOs en JSON
  5. Réponse envoyée au client

Comprendre le format JSON-LD

Vous avez peut-être remarqué que les réponses API contiennent des propriétés étranges comme @context, @id, @type. C'est le format JSON-LD (JSON Linked Data), activé par défaut dans API Platform.

{
"@context": "/api/contexts/Region",
"@id": "/api/regions",
"@type": "Collection",
"totalItems": 5,
"member": [
{
"@id": "/api/regions/1f0ddc27-480d-65e8-9eae-e31b2cd94dd7",
"@type": "Region",
"code": "CON",
"name": "Conakry",
"isActif": true
}
]
}

Pourquoi c'est cool ?

@context : définit le contexte sémantique (vocabulaire utilisé)
@id : URL unique de la ressource (utile pour les liens HATEOAS)
@type : type de la ressource (Region, Collection, etc.)
member : tableau des éléments de la collection

Les avantages :

  • Auto-découverte : les clients (front-end, apps mobiles) savent comment naviguer dans l'API
  • Standards Web : compatible avec les outils du Web sémantique
  • HATEOAS : Hypermedia As The Engine Of Application State (REST niveau 3)
  • Documentation vivante : la structure de l'API est dans les réponses

Pagination automatique

Lorsque vos données grandiront, API Platform renverra automatiquement des liens de pagination dans la réponse. Cela aide grandement les développeurs front-end à naviguer dans vos collections :

{
"@context": "/api/contexts/Region",
"@id": "/api/regions",
"@type": "Collection",
"totalItems": 150,
"member": [
// ... vos données (30 par défaut, configurable avec paginationItemsPerPage dans #[ApiResource] le RegionDto)
],
"view": {
"@id": "/api/regions?page=1",
"@type": "PartialCollectionView",
"first": "/api/regions?page=1",
"last": "/api/regions?page=4",
"next": "/api/regions?page=2"
}
}

La propriété view contient :

  • first : première page
  • last : dernière page
  • next : page suivante (si elle existe)
  • previous : page précédente (si elle existe)
Pour les développeurs front-end

Avec ces liens, il suffit de faire une requête GET sur view.next pour récupérer la page suivante. Plus besoin de calculer manuellement les offsets ! L'API vous guide automatiquement.

Vous préférez du JSON simple ?

Pas de souci ! API Platform supporte plusieurs formats. Pour utiliser du JSON classique, ajoutez simplement dans votre DTO :

#[ApiResource(
formats: ['json' => ['application/json']],
// ... autres options
)]
class RegionDto extends BaseDto

Ou spécifiez le format dans la requête avec le header Accept: application/json au lieu de application/ld+json.

Flexibilité

API Platform supporte aussi JSON:API, HAL, XML... Vous pouvez même créer vos propres formats ! Mais JSON-LD est un excellent choix par défaut pour des APIs modernes.

User Story : ajouter une nouvelle fonctionnalité

Maintenant qu'on a fini notre API de base, imaginons qu'on aimerait ajouter une petite Feat demandée par le client :

"En tant que développeur front-end, je veux pouvoir filtrer les régions actives uniquement, et je veux pouvoir désactiver une région via l'API."

Avec Symfony classique, vous devriez :

  1. Créer un QueryBuilder personnalisé
  2. Ajouter des paramètres de requête manuels
  3. Gérer la validation
  4. Modifier votre contrôleur
  5. Documenter l'endpoint...

Avec API Platform ? On ajoute juste une ligne !

Étape 1 : Permettre la modification de isActif

Actuellement, le champ isActif n'est pas modifiable car :

  1. On ne le gère pas dans le Mapper
  2. Il est défini comme writable: false dans le BaseDto

Modifions d'abord le BaseDto pour rendre isActif modifiable :

src/ApiResource/BaseDto.php
<?php

namespace App\ApiResource;

use ApiPlatform\Metadata\ApiProperty;

abstract class BaseDto
{
#[ApiProperty(readable: false, writable: false, identifier: true)]
public ?string $id = null;

#[ApiProperty(readable: true, writable: true)]
public ?bool $isActif = true;
}
writable: true

En passant writable: true, on autorise les clients à modifier ce champ via POST/PATCH. C'est ce qui permettra d'activer/désactiver une région depuis l'API.

Ensuite, modifions RegionDtoToEntityMapper pour gérer la modification :

src/Mapper/RegionDtoToEntityMapper.php
<?php

namespace App\Mapper;

use App\ApiResource\RegionDto;
use App\Entity\Region;
use App\Repository\RegionRepository;
use Symfonycasts\MicroMapper\AsMapper;
use Symfonycasts\MicroMapper\MapperInterface;

#[AsMapper(from: RegionDto::class, to: Region::class)]
class RegionDtoToEntityMapper implements MapperInterface
{
public function __construct(private RegionRepository $regionRepository)
{
}

public function load(object $from, string $toClass, array $context): object
{
$dto = $from;
assert($dto instanceof RegionDto);

return $dto->id ? $this->regionRepository->find($dto->id) : new Region();
}

public function populate(object $from, object $to, array $context): object
{
$dto = $from;
$entity = $to;

assert($dto instanceof RegionDto);
assert($entity instanceof Region);

$entity->setCode($dto->code);
$entity->setName($dto->name);

// Permettre la modification de isActif via PATCH
if (isset($dto->isActif)) {
$entity->setIsActif($dto->isActif);
}

return $entity;
}
}
Modification partielle (PATCH)

On utilise isset() pour ne modifier isActif que si le champ est présent dans la requête. C'est le principe du PATCH : modifier seulement les champs envoyés.

Étape 2 : Tester la désactivation

Testez avec Swagger UI : PATCH /api/regions/{id}

Body :

{
"isActif": false
}

Réponse (Status 200 OK) :

{
"@context": "/api/contexts/Region",
"@id": "/api/regions/1f0ddc27-480d-65e8-9eae-e31b2cd94dd7",
"@type": "Region",
"code": "CON",
"name": "Conakry",
"isActif": false
}

Ça marche ! Le client (front-end, apps mobiles) peut maintenant désactiver des régions.

Étape 3 : Ajouter le filtre sur isActif

Maintenant, ajoutons la possibilité de filtrer les régions actives. Modifiez RegionDto :

src/ApiResource/RegionDto.php
<?php

namespace App\ApiResource;

use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
use ApiPlatform\Metadata\ApiFilter;
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 ApiPlatform\Doctrine\Orm\State\Options;
use App\Entity\Region;

#[ApiResource(
shortName: 'Region',
operations: [
new Get(),
new GetCollection(),
new Post(),
new Patch(),
new Delete(openapi: false, security: 'is_granted("ROLE_ADMIN")'),
],
paginationItemsPerPage: 10,
stateOptions: new Options(entityClass: Region::class),
)]
#[ApiFilter(BooleanFilter::class, properties: ['isActif'])]
class RegionDto extends BaseDto
{
public ?string $code = null;
public ?string $name = null;
}

C'est tout !

Étape 4 : Tester le filtre

Testez dans le navigateur ou sur Postman. Par exemple, pour récupérer uniquement les régions désactivées :

GET /api/regions?isActif=false

{
"@context": "/api/contexts/Region",
"@id": "/api/regions",
"@type": "Collection",
"totalItems": 1,
"member": [
{
"@id": "/api/regions/1f0ddc27-480d-65e8-9eae-e31b2cd94dd7",
"@type": "Region",
"code": "CON",
"name": "Conakry - Capitale",
"isActif": false
}
],
"view": {
"@id": "/api/regions?isActif=false",
"@type": "PartialCollectionView"
},
"search": {
"@type": "IriTemplate",
"template": "/api/regions{?isActif}",
"variableRepresentation": "BasicRepresentation",
"mapping": [
{
"@type": "IriTemplateMapping",
"variable": "isActif",
"property": "isActif",
"required": false
}
]
}
}

Bien sûr, le client peut aussi utiliser GET /api/regions?isActif=true pour récupérer uniquement les régions actives.

Propriété search

La clé search est un bonus JSON-LD : elle expose un template IRI qui décrit dynamiquement les filtres disponibles sur cette ressource. Ici, elle indique que le paramètre isActif existe, qu'il correspond à la propriété isActif de l'entité, et qu'il est optionnel (required: false).

Pourquoi c'est utile ?
Les clients intelligents (front-end auto-généré, outils de documentation) peuvent découvrir automatiquement comment filtrer l'API sans lire la doc. C'est le principe du Web sémantique : l'API s'auto-documente !

Comment ça marche ?

#[ApiFilter(BooleanFilter::class, properties: ['isActif'])]

  • BooleanFilter : filtre pour les propriétés booléennes
  • properties: ['isActif'] : liste des champs filtrables

API Platform génère automatiquement :

  • Le paramètre de requête ?isActif=true ou ?isActif=false
  • La requête SQL optimisée (WHERE is_actif = 1)
  • La documentation Swagger avec le filtre visible
Autres filtres disponibles

API Platform propose plein de filtres prêts à l'emploi :

  • SearchFilter : recherche textuelle (exact, partial, start, end)
  • DateFilter : filtres sur dates (before, after, strictly_before...)
  • RangeFilter : filtres numériques (between, gt, gte, lt, lte)
  • OrderFilter : tri (asc, desc)
  • ExistsFilter : vérifier si un champ est null ou non

Exemple :

#[ApiFilter(SearchFilter::class, properties: ['name' => 'partial'])]
#[ApiFilter(OrderFilter::class, properties: ['name', 'code'])]

Avec ça, vous auriez : GET /api/regions?name=Lab&order[name]=asc

Récapitulatif de la User Story

Modification de isActif : ajout de 4 lignes dans le Mapper
Filtre sur isActif : ajout d'une seule ligne dans le DTO
Documentation Swagger : générée automatiquement
Requêtes SQL optimisées : gérées par API Platform

Temps estimé avec Symfony classique : 2-3 heures
Temps avec API Platform : 5 minutes

Conclusion

Félicitations ! Vous venez de créer une API REST complète avec une architecture propre et réutilisable. En quelques fichiers, vous avez mis en place :

  • Une séparation Entity/DTO pour protéger votre base de données
  • Des States génériques réutilisables pour toutes vos futures ressources
  • Des Mappers MicroMapper pour des transformations propres
  • Des filtres déclaratifs ajoutés en une ligne
  • Une documentation Swagger générée automatiquement
  • Une API JSON-LD auto-documentée avec pagination et HATEOAS

API Platform, c'est du vibe-coding avant l'heure : vous déclarez ce que vous voulez, le framework génère le reste. Résultat ? Un gain de temps considérable, un code maintenable, et une API moderne sans effort.

Ajouter une nouvelle ressource ? C'est simple !

Grâce à l'architecture générique qu'on vient de mettre en place, créer une nouvelle ressource API devient trivial :

  1. Créez l'Entity (qui hérite de BaseEntity)
  2. Créez le DTO dans ApiResource/ (qui hérite de BaseDto)
  3. Créez les 2 Mappers (EntityToDto et DtoToEntity)

C'est tout ! Les States génériques fonctionnent automatiquement. Plus besoin de recréer la logique Provider/Processor pour chaque ressource.

Exemple rapide : pour ajouter une ressource Product :

// 1. src/Entity/Product.php
class Product extends BaseEntity {
private ?string $name = null;
private ?float $price = null;
}

// 2. src/ApiResource/ProductDto.php
#[ApiResource(
operations: [new Get(), new GetCollection(), new Post(), new Patch()],
provider: EntityToDtoStateProvider::class,
processor: EntityClassDtoStateProcessor::class,
stateOptions: new Options(entityClass: Product::class)
)]
class ProductDto extends BaseDto {
public ?string $name = null;
public ?float $price = null;
}

// 3. src/Mapper/ProductEntityToDtoMapper.php + ProductDtoToEntityMapper.php

Votre API /api/products est prête ! Aucun code de State à réécrire.

Et après ?

Ce tutoriel pose les bases. Dans les prochains chapitres, nous verrons :

  • Les Custom Extensions : ajouter de la logique métier avancée (calculs, agrégations, événements)
  • La sécurisation : JWT, permissions avancées sur les ressources
  • Les relations complexes : OneToMany, ManyToMany avec normalization groups
  • La validation avancée : contraintes personnalisées, validation conditionnelle
  • Les tests : API Platform Test, fixtures, assertions JSON-LD

Vous avez maintenant les clés pour construire des APIs professionnelles. Maintenant, c'est à vous de jouer !