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'APIsrc/Mapper/: les mappers pour transformer Entity ↔ DTOsrc/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 ?
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

Ajoutez les deux champs :
code(string, 8)name(string, 255)
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 :
<?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;
}
}
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'
idest stocké enstring(UUID au format texte) UuidGeneratorgé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 :
<?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;
}
}
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 :
- Vous créez une classe (Entity ou DTO)
- Vous ajoutez
#[ApiResource]dessus - 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.
- 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 ?
- 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 :
<?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})
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 :
<?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 IDGetCollection: lister toutes les régionsPost: créer une régionPatch: modifier partiellement une régionDelete: supprimer (réservé aux admins, caché de la doc OpenAPI)
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 !

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 :
- Créer des Controllers (
RegionController) - Gérer les routes (
#[Route('/api/regions', methods: ['GET'])]) - Récupérer les données via le Repository
- Transformer les objets en JSON manuellement
- Gérer la pagination, les filtres, la validation...
- 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...)
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 :
<?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 :
- Récupère l'entité depuis Doctrine (via
CollectionProviderouItemProvider) - Transforme Entity → DTO avec le MicroMapper
- 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 :
<?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 :
- Transforme DTO → Entity avec le MicroMapper
- Persiste l'entité en base (via
PersistProcessor) - Supprime l'entité si c'est un DELETE (via
RemoveProcessor) - Retourne le DTO avec l'ID mis à jour (important pour les POST)
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 :
<?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 GETprocessor: 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 :
- Vous créez un Mapper pour chaque sens de transformation
RegionEntityToDto: transformeRegion→RegionDtoRegionDtoToEntity: transformeRegionDto→Region- MicroMapper les auto-découvre et les utilise automatiquement
Une fois les Mappers créés, la boucle sera bouclée :
- Requête API → DTO reçu
- Processor → Mapper transforme DTO → Entity
- Entity → sauvegardée en BDD
- Provider → récupère Entity
- Mapper transforme Entity → DTO
- DTO → renvoyé en JSON
Étape 1 : Créer le Mapper Entity → DTO
Créez le fichier 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'IDpopulate(): remplit les propriétés du DTO avec les valeurs de l'Entity
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 :
<?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 nouvellepopulate(): met à jour les propriétés de l'Entity avec les valeurs du DTO
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 :
- Cliquez sur l'opération (ex:
GET /api/regions) - Cliquez sur "Try it out"
- Cliquez sur "Execute"

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 :

{
"@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)
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.
Voilà ce qui se passe maintenant pour un GET /api/regions :
- API Platform reçoit la requête
- Provider récupère les
Regiondepuis Doctrine - Mapper transforme chaque
Region→RegionDto - API Platform sérialise les DTOs en JSON
- 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 pagelast: dernière pagenext: page suivante (si elle existe)previous: page précédente (si elle existe)
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.
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 :
- Créer un QueryBuilder personnalisé
- Ajouter des paramètres de requête manuels
- Gérer la validation
- Modifier votre contrôleur
- 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 :
- On ne le gère pas dans le Mapper
- Il est défini comme
writable: falsedans leBaseDto
Modifions d'abord le BaseDto pour rendre isActif modifiable :
<?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;
}
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 :
<?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;
}
}
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 :
<?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.
searchLa 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éennesproperties: ['isActif']: liste des champs filtrables
API Platform génère automatiquement :
- Le paramètre de requête
?isActif=trueou?isActif=false - La requête SQL optimisée (
WHERE is_actif = 1) - La documentation Swagger avec le filtre visible
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
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 :
- Créez l'Entity (qui hérite de
BaseEntity) - Créez le DTO dans
ApiResource/(qui hérite deBaseDto) - Créez les 2 Mappers (
EntityToDtoetDtoToEntity)
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 !