Skip to Content

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 via normalizationContext (sortie) et denormalizationContext (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) : uniquement customerEmail et start (le client ne peut pas fixer id, status ni createdAt).

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é User exposé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 (BookingSlot), 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 Booking cô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 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.