Chapitre 11.2 — Sérialisation : groupes & DTO
Partie 11 : API Platform — Chapitre 2/8 Version de référence : API Platform 4 / Symfony 7.4.
⏱️ TL;DR
- La sérialisation transforme un objet en JSON (normalization, sortie) et le JSON en objet (denormalization, entrée). API Platform s’appuie sur le Serializer de Symfony.
- Les groupes de sérialisation (
#[Groups(['booking:read'])]) contrôlent quels champs sortent et entrent. On les active par resource/opération vianormalizationContext(sortie) etdenormalizationContext(entrée).- Exposez le juste nécessaire : cachez les champs sensibles (mot de passe, données internes), différenciez lecture et écriture.
- Un getter annoté d’un groupe devient un champ calculé (virtuel) dans le JSON.
- Pour des API complexes, exposez des DTO plutôt que l’entité brute : la forme de l’API est découplée du modèle de base.
- Pour un dev Next.js : les groupes ≈ des « select » de champs par vue ; le DTO ≈ le type de réponse de votre API, distinct du modèle DB.
🎯 Objectifs
- Contrôler les champs exposés avec les groupes.
- Différencier entrée (write) et sortie (read).
- Ajouter des champs calculés.
- Savoir quand passer par un DTO.
1. Le problème : #[ApiResource] nu expose tout
Sans configuration, API Platform expose toutes les propriétés (lisibles) de l’entité — y compris des champs internes ou sensibles. On veut choisir ce qui sort et ce qui entre. C’est le rôle des groupes de sérialisation.
2. Les groupes
On annote chaque propriété avec les groupes auxquels elle appartient, puis on active ces groupes par resource :
use ApiPlatform\Metadata\ApiResource;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
normalizationContext: ['groups' => ['booking:read']], // champs en SORTIE
denormalizationContext: ['groups' => ['booking:write']], // champs en ENTRÉE
)]
class Booking
{
#[Groups(['booking:read'])] // lisible, pas modifiable via l'API
private ?int $id = null;
#[Groups(['booking:read', 'booking:write'])] // lisible ET modifiable
private string $customerEmail;
#[Groups(['booking:read', 'booking:write'])]
private \DateTimeImmutable $start;
#[Groups(['booking:read'])] // statut : lecture seule via l'API
private BookingStatus $status = BookingStatus::Pending;
#[Groups(['booking:read'])] // horodatage : sortie uniquement
private \DateTimeImmutable $createdAt;
// un champ NON annoté n'apparaît NI en entrée NI en sortie → invisible
}Résultat :
- En sortie (
GET) :id,customerEmail,start,status,createdAt. - En entrée (
POST/PATCH) : uniquementcustomerEmailetstart(le client ne peut pas fixerid,statusnicreatedAt).
C’est exactement le contrôle qu’on veut : le client crée une réservation avec un email et une date, mais ne choisit pas son statut ni son id.
⚠️ Piège sécurité (over-exposure) : sans groupes, une entité
Userexposée en#[ApiResource]divulgue le hash du mot de passe, les rôles, les données internes — et permet potentiellement de les modifier (mass assignment via l’API). Toujours définir des groupes read/write explicites et n’exposer que le nécessaire. Un champ sensible sans groupe = invisible ; c’est le comportement voulu.
3. Groupes par opération
On peut affiner par opération (ex. exposer plus de champs sur le détail que sur la liste) :
use ApiPlatform\Metadata\{ApiResource, Get, GetCollection};
#[ApiResource(operations: [
new GetCollection(normalizationContext: ['groups' => ['booking:list']]), // liste : minimal
new Get(normalizationContext: ['groups' => ['booking:read', 'booking:detail']]), // détail : complet
])]Utile pour des listes légères (moins de champs, plus rapides) et des détails riches.
4. Champs calculés (virtuels)
Un getter annoté d’un groupe apparaît dans le JSON, même s’il ne correspond pas à une colonne :
#[Groups(['booking:read'])]
public function getStatusLabel(): string
{
return $this->status->label(); // ajoute "statusLabel" dans la réponse
}Pratique pour exposer une valeur dérivée (libellé, total calculé, URL) sans la stocker.
5. Relations : imbriquer ou référencer
Pour une relation (Booking → Slot), deux stratégies via les groupes :
- Référencer (par défaut en JSON-LD) : la relation apparaît comme un IRI (
"slot": "/api/slots/12"). Léger ; le client charge le slot séparément si besoin. - Imbriquer : mettre un groupe partagé pour inclure les champs du slot dans la réponse (
"slot": { "id": 12, "start": "..." }).
// dans Booking
#[Groups(['booking:read'])]
private ?Slot $slot = null;
// dans Slot : les champs exposés quand on imbrique depuis booking:read
#[Groups(['booking:read', 'slot:read'])]
private \DateTimeImmutable $start;⚠️ Piège : imbriquer trop profondément (booking → slot → service → provider → …) génère des réponses énormes et peut boucler (relations circulaires). Contrôlez la profondeur avec
#[MaxDepth](+enable_max_depth), et préférez référencer (IRI) ce que le client peut charger à la demande. Exposez des arbres plats ; laissez le client composer.
6. Quand passer par un DTO
Lier l’API directement à l’entité est simple, mais couple la forme de l’API au modèle de base. Dès que l’API doit avoir une forme différente (agréger plusieurs entités, masquer la structure interne, versionner indépendamment), on expose un DTO :
#[ApiResource(
operations: [new Get(), new GetCollection()],
// provider custom qui construit le DTO à partir des entités
provider: BookingOutputProvider::class,
)]
final class BookingOutput
{
public function __construct(
public int $id,
public string $customerEmail,
public string $statusLabel,
public string $providerName, // aplati depuis booking→slot→service→provider
) {}
}Un state provider/processor (chapitre 11.1) fait le pont DTO ↔ entités. Avantages : forme d’API stable et découplée, pas de fuite du modèle interne, versioning facilité. C’est la même logique DTO que pour les formulaires (Partie 9) — appliquée à la sortie.
💡 Pour un dev Next.js : le DTO de sortie API = le type de réponse que consommera votre front (le
type Bookingcôté TS). Le séparer de l’entité Doctrine, c’est garder la liberté de changer la base sans casser le contrat d’API — exactement la séparation « API type » vs « DB model » que vous soignez déjà. Et ce DTO alimente la génération de types TS (chapitre 11.8).
✏️ Exercices
Exercice 1. Configurez Booking pour qu’en création, le client ne puisse fournir que customerEmail et start, et qu’en lecture il voie aussi id, status et createdAt.
✅ Solution
#[ApiResource(
normalizationContext: ['groups' => ['booking:read']],
denormalizationContext: ['groups' => ['booking:write']],
)]
class Booking
{
#[Groups(['booking:read'])] private ?int $id = null;
#[Groups(['booking:read', 'booking:write'])] private string $customerEmail;
#[Groups(['booking:read', 'booking:write'])] private \DateTimeImmutable $start;
#[Groups(['booking:read'])] private BookingStatus $status;
#[Groups(['booking:read'])] private \DateTimeImmutable $createdAt;
}customerEmail/start sont en read et write ; id/status/createdAt en read seul → non modifiables par le client.
Exercice 2. Pourquoi exposer une entité User en #[ApiResource] sans groupes est-il dangereux ?
✅ Solution
Sans groupes, toutes les propriétés lisibles sont exposées — dont le hash du mot de passe, les rôles et des champs internes — et potentiellement modifiables via l’API (mass assignment : un utilisateur pourrait s’attribuer ROLE_ADMIN). Il faut des groupes read/write explicites n’exposant que le nécessaire (jamais le mot de passe ; les rôles en lecture seule ou non exposés).
Exercice 3. Vous voulez exposer un providerName aplati (venant de booking→slot→service→provider) sans imbrication profonde. Comment ?
✅ Solution
Deux options : (1) un getter calculé getProviderName() annoté du groupe read qui parcourt la chaîne et renvoie le nom (attention au N+1 → charger la relation en fetch join côté provider) ; (2) exposer un DTO de sortie BookingOutput (avec un state provider) qui aplati la valeur. La seconde découple mieux l’API du modèle et évite d’imbriquer 4 niveaux de relations.
🧠 Quiz de révision
1. À quoi servent normalizationContext et denormalizationContext ?
normalizationContext et denormalizationContext ?normalizationContext définit les groupes en sortie (ce que l’API renvoie) ; denormalizationContext les groupes en entrée (ce que le client peut fournir).
2. Que se passe-t-il pour une propriété sans groupe ?
Elle est invisible : ni exposée en sortie, ni acceptée en entrée. C’est le comportement voulu pour les champs sensibles/internes.
3. Comment ajoute-t-on un champ calculé au JSON ?
Par un getter annoté d’un groupe (#[Groups(['x:read'])] public function getLabel()), qui apparaît comme un champ virtuel.
4. Imbriquer ou référencer une relation : quel risque à trop imbriquer ?
Des réponses énormes et des boucles (relations circulaires). On limite avec #[MaxDepth] et on préfère référencer (IRI) ce que le client peut charger à la demande.
5. Pourquoi exposer un DTO plutôt que l’entité ?
Pour découpler la forme de l’API du modèle de base : forme stable, pas de fuite interne, versioning facilité, et alimentation de la génération de types TS.
Prochain chapitre : 11.3 — Filtres, pagination & tri — rendre les collections exploitables.