Serializer
TL;DR — Le composant Serializer transforme objets PHP ↔ formats (JSON, XML, CSV, YAML). L'architecture est en deux couches : Normalizers (objet ↔ tableau associatif) et Encoders (tableau ↔ string). Les groupes contrôlent quels champs sont exposés selon le contexte. Pour de la performance, active le cache des métadonnées et borne la profondeur. C'est le moteur derrière API Platform, mais utilisable seul.
🧠 Mental model — ASCII diagram + analogy
┌──────────────────┐
│ Serializer │
│ (orchestrator) │
└────────┬─────────┘
│
┌──────────────┴──────────────┐
▼ ▼
┌─────────────┐ ┌────────────┐
│ Normalizers │ │ Encoders │
│ obj ↔ array │ │ array ↔ str│
└─────────────┘ └────────────┘
│ │
┌──────────┼──────────┐ ┌────────┼────────┐
▼ ▼ ▼ ▼ ▼ ▼
Object DateTime Uid... Json Xml Csv
Normalizer Normalizer Encoder Encoder EncoderAnalogie : pense au Serializer comme un traducteur en deux étapes. D'abord tu transformes un objet métier en array (langue pivot), puis tu encodes cet array en JSON/XML/etc. (langue cible). Chaque normalizer gère un type d'objet (DateTime, Uid, Money...), chaque encoder gère un format. Tu peux remplacer/ajouter à chaque étage.
L'ordre des normalizers compte : ils sont triés par priorité (tag serializer.normalizer, attribut priority). Le premier qui supportsNormalization() gagne.
🛠️ Code minimal — realistic snippet (PHP 8.2+)
<?php
// src/Dto/UserDto.php
namespace App\Dto;
// Symfony 7.x : namespace `Attribute\*` (l'ancien `Annotation\*` est déprécié depuis 6.4)
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Attribute\SerializedName;
use Symfony\Component\Serializer\Attribute\MaxDepth;
final class UserDto
{
public function __construct(
#[Groups(['user:read', 'user:list'])]
public readonly int $id,
#[Groups(['user:read', 'user:list', 'user:write'])]
#[SerializedName('email_address')]
public readonly string $email,
#[Groups(['user:read', 'user:write'])]
public readonly ?string $fullName = null,
#[Groups(['user:read'])]
public readonly \DateTimeImmutable $createdAt = new \DateTimeImmutable(),
/** @var OrderDto[] */
#[Groups(['user:read'])]
#[MaxDepth(1)]
public readonly array $orders = [],
) {}
}<?php
// Usage in a controller
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Serializer\SerializerInterface;
final class UserController
{
public function __construct(private SerializerInterface $serializer) {}
public function show(UserDto $user): JsonResponse
{
$json = $this->serializer->serialize($user, 'json', [
'groups' => ['user:read'],
'enable_max_depth' => true,
'datetime_format' => \DateTimeInterface::ATOM,
]);
return new JsonResponse($json, 200, [], true);
}
public function create(Request $req): JsonResponse
{
$dto = $this->serializer->deserialize(
$req->getContent(),
UserDto::class,
'json',
['groups' => ['user:write']],
);
// $dto is a typed UserDto, ready to validate
}
}<?php
// src/Serializer/MoneyNormalizer.php — custom normalizer
namespace App\Serializer;
use App\ValueObject\Money;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
final class MoneyNormalizer implements NormalizerInterface, DenormalizerInterface
{
public function normalize(mixed $object, ?string $format = null, array $context = []): array
{
\assert($object instanceof Money);
return [
'amount' => $object->getAmount(), // string "12.50"
'currency' => $object->getCurrency(), // "EUR"
];
}
public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool
{
return $data instanceof Money;
}
public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): Money
{
return new Money($data['amount'], $data['currency']);
}
public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool
{
return $type === Money::class;
}
public function getSupportedTypes(?string $format): array
{
return [Money::class => true]; // cacheable
}
}# config/packages/framework.yaml
framework:
serializer:
# Les attributs PHP sont activés par défaut depuis Symfony 6.x.
# `enable_annotations` a été retiré en 7.0 ; `enable_attributes` existe encore
# mais vaut `true` par défaut — inutile de le déclarer.
default_context:
skip_null_values: true
datetime_format: !php/const \DateTimeInterface::ATOM
# Mapping additionnel (rarement nécessaire : les attributs sont découverts automatiquement).
mapping:
paths: ['%kernel.project_dir%/src/Dto']# config/packages/prod/framework.yaml — cache des métadonnées en prod
# Le cache pool `serializer.mapping.cache.pool` est câblé automatiquement par le
# framework-bundle. Le warmup `serializer.mapping.cache_warmer` peuple le cache
# au `cache:warmup` du deploy → zéro coût de réflexion au premier appel runtime.
framework:
serializer:
default_context:
skip_null_values: true🎯 Patterns courants
DTO ≠ Entity — sérialise des DTO immuables, pas tes entités Doctrine. Évite les fuites de champs internes, casse le couplage DB ↔ API, simplifie les groupes.
Groupes nommés par contexte —
book:read,book:write,book:admin. Préfère des groupes fins que tu combines (['book:read', 'book:admin']) plutôt qu'un gros groupe par rôle.#[SerializedName]pour le snake_case — quand l'API utilisecreated_atmais le code$createdAt. Alternative :NameConverterInterface(e.g.CamelCaseToSnakeCaseNameConverter, ouMetadataAwareNameConverterqui respecte#[SerializedName]) appliqué globalement.Custom Normalizer + ObjectNormalizer décoré — pour ajouter des champs calculés à la volée :
php#[AsDecorator('serializer.normalizer.object')] final class BookEnrichingNormalizer implements NormalizerInterface { /* ... */ }Denormalization to typed objects —
serializer.deserialize($json, UserDto::class, 'json')instancie un DTO typé. Combine avecValidator(#[Assert\...]) pour valider l'input avant de toucher la DB. Plus sûr que$request->toArray().Contexte par défaut —
framework.serializer.default_contextévite de répéterskip_null_values,datetime_format, etc. à chaque appel.
🧭 Comment un staff engineer raisonne là-dessus
Le Serializer n'est pas un détail d'implémentation : c'est la frontière de ton bounded context exposée en HTTP. Les décisions ici fuitent dans le contrat d'API que des clients tiers vont graver dans le marbre. Trois axes de réflexion structurent toutes les autres.
1. Où vit le contrat ? Soit dans tes entités (annotations #[Groups] directement sur Doctrine — rapide à écrire, dangereux à maintenir), soit dans des DTO dédiés (verbeux, mais le contrat est explicite et versionnable). La règle de staff : l'entité est un détail de persistance, le DTO est le contrat. Dès qu'une API a >1 consommateur ou doit survivre à un refactor de schéma DB, tu passes en DTO. Le coût du mapping est réel mais borné ; le coût d'un champ DB qui fuite en prod est non borné.
2. Réflexion runtime vs métadonnées compilées. À froid, ObjectNormalizer fait de la réflexion sur chaque classe (lecture des attributs, résolution des accesseurs via PropertyAccess). C'est cher. Le ClassMetadataFactory met ça en cache (PSR-6). Un staff engineer warmup le cache au deploy et mesure : sans cache, sérialiser 1000 objets peut coûter 10× plus. Le warmup est gratuit (il tourne au cache:warmup), donc l'oublier est une faute, pas un arbitrage.
3. Couplage groupes ↔ sécurité. Les groupes décident quels champs sortent. Si la sélection du groupe dépend du rôle de l'appelant, alors le Serializer fait partie de ta surface d'autorisation. Une erreur de groupe = une fuite de données, pas un bug cosmétique. D'où : tests d'isolation systématiques (un test par couple rôle×champ-sensible), et un groupe par défaut restrictif (whitelist), jamais "tout sauf".
Tableau de décision — Serializer vs alternatives
| Besoin | Choix | Pourquoi |
|---|---|---|
Sérialiser un array plat, hot path | json_encode() | ~10× plus rapide, zéro réflexion. Le Serializer ne se justifie pas. |
| Objets + plusieurs vues (groupes) | Serializer + DTO | Contrat explicite, multi-format, denormalize typé. |
| Transformation très métier (calculs, agrégats) | Mapper explicite (UserMapper::toDto) | Se lit comme du code, débuggable au pas-à-pas, pas un graphe d'attributs implicite. |
| API publique versionnée, hypermedia | API Platform (au-dessus du Serializer) | State providers/processors, pagination, OpenAPI, IRIs gratuits. |
| Schéma totalement dynamique | json_decode(..., true) + validation manuelle | Pas de classe cible stable → le Serializer n'apporte rien. |
Tableau de décision — Entité vs DTO
| Critère | Annotations sur l'entité | DTO dédié |
|---|---|---|
| Vitesse d'écriture initiale | ✅ rapide | ❌ boilerplate |
| Risque de fuite de champ | ❌ élevé (tout champ ajouté en DB peut sortir) | ✅ explicite, opt-in |
| Découplage DB ↔ contrat API | ❌ couplé | ✅ total |
Versionnement d'API (v1/v2) | ❌ difficile | ✅ un DTO par version |
| Lazy-loading Doctrine accidentel en sérialisant | ❌ piège fréquent (N+1) | ✅ DTO hydraté explicitement |
| Verdict staff | Prototype / API interne jetable | Tout le reste |
🔭 Production : perf, sécurité, observabilité
Performance.
- Warmup obligatoire. En prod,
bin/console cache:warmupdoit peuplerserializer.mapping.cache. Vérifie-le :php bin/console debug:container --tag=serializer.normalizerdoit lister tes normalizers, et le pool de cache doit êtrecache.system(pascache.appvolatil). getSupportedTypes()est non-négociable. Sans lui,supportsNormalization()est rappelé pour chaque champ de chaque objet. Sur une collection de 1000 items × 10 champs = 10 000 passages dans toute la chaîne de normalizers. Avec, la résolution est mémoïsée par type. Retourne[Type::class => true]quand le support ne dépend que du type (cacheable),falsequand il dépend de la valeur runtime.- Sérialise des listes, pas des graphes. Un
OneToManylazy sérialisé déclenche un N+1. Hydrate explicitement (fetch: EAGERciblé, ou DTO construit par un repository avecJOIN), ou borne viaMaxDepth. - Mesure réelle. Le blackfire/profiler Symfony montre le temps passé dans
Serializer::serialize. Un listing qui passe 40 % de son temps en sérialisation = signal de DTO plats côté repository (projection SQL → array →json_encode).
Sécurité.
- Whitelist, jamais blacklist. Un champ sans
#[Groups]ne sort pas si tu passes toujours ungroupsdans le contexte. Forcegroupsau niveau framework ou via unContextBuilderqui refuse un contexte vide. - Mass assignment en denormalize.
deserialize($body, User::class, 'json')peut écraserisAdmin,roles,idsi ces champs sont dans le groupe d'écriture. Sépare strictementuser:write(champs éditables) deuser:read. Idéalement, denormalize vers un DTO d'input qui ne contient pas les champs privilégiés. AbstractNormalizer::ALLOW_EXTRA_ATTRIBUTES => falsefait échouer la denormalization si le payload contient des clés inconnues — défense en profondeur contre les champs injectés.- Pas d'objets arbitraires. Ne denormalize jamais un
typefourni par le client (risque d'instanciation de classe arbitraire). Le type cible est toujours fixé côté serveur.
Observabilité.
- Loggue les
NotNormalizableValueException/PartialDenormalizationExceptionavec le champ fautif : en 6.3+, passeDenormalizerInterface::COLLECT_DENORMALIZATION_ERRORS => truepour récupérer toutes les erreurs d'un coup (mappable sur une réponse 422 RFC 7807) au lieu de la première. - Expose un health-check qui sérialise un objet sentinelle au boot : détecte une métadonnée cassée avant le premier appel client.
- Trace le
groupseffectif dans les logs d'accès (sans les valeurs) : indispensable pour auditer une fuite a posteriori (« quel groupe a exposé ce champ ? »).
<?php
// Denormalize "tolérant" : collecte toutes les erreurs → réponse 422 RFC 7807
use Symfony\Component\Serializer\Exception\PartialDenormalizationException;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
try {
$dto = $serializer->deserialize($json, UserDto::class, 'json', [
DenormalizerInterface::COLLECT_DENORMALIZATION_ERRORS => true,
AbstractNormalizer::ALLOW_EXTRA_ATTRIBUTES => false,
]);
} catch (PartialDenormalizationException $e) {
$violations = [];
foreach ($e->getErrors() as $error) {
$violations[] = [
'field' => implode('.', $error->getPath() ?? []),
'expected' => $error->getExpectedTypes(),
];
}
// -> 422 application/problem+json
}🔄 Versions
| Symfony | Changements Serializer |
|---|---|
| 5.4 | Annotations + attributes coexistent. NameConverter configurable. |
| 6.0 | Annotations Doctrine déprécié, mais attributes Serializer toujours OK. |
| 6.3 | getSupportedTypes() méthode introduite (cacheability). Préférer plutôt que supportsNormalization() qui est appelé à chaque normalize. |
| 6.4 | LTS. enable_max_depth toujours opt-in. |
| 7.0 | supportsNormalization() doit être implémenté avec getSupportedTypes() pour bénéficier du cache. Méthode normalize() retourne array|string|int|float|bool|\ArrayObject|null. Signatures strictes. |
| 7.1+ | BackedEnumNormalizer dans le core, support natif Uid, Translatable. |
API Platform impact : en 3.x, API Platform s'appuie fortement sur getSupportedTypes() pour son cache de metadata → custom normalizers doivent l'implémenter ou perf chute.
⚠️ Pitfalls
supportsNormalization()appelé à chaque sérialisation — sansgetSupportedTypes(), chaque champ d'un gros objet repasse par toute la chaîne. Sur des collections de 1000+ items, ça explose. Toujours implémentergetSupportedTypes()(Symfony 6.3+).Cycles infinis —
Author → Books → Authorboucle. Solutions : (a)MaxDepth+enable_max_depth: true, (b) groupes disjoints, (c)CircularReferenceHandlerqui retourne l'IRI ou l'ID.nullvs absent —skip_null_values: trueomet les champsnull. Mais en JSON Merge Patch,nullsignifie "supprime ce champ" → gardeskip_null_values: falsepour PATCH.DateTime denormalization stricte — si le format ne matche pas exactement
datetime_format, exception. Configuredatetime_formatou utilise plusieurs formats via custom normalizer.Propriétés
privatesans accesseur —ObjectNormalizerutilise PropertyAccess. Sans getter/setter et sans#[Ignore], ça lanceNoSuchPropertyException. Pour lesreadonly, OK en lecture, mais en denormalize il faut le constructeur.Constructeur avec types union complexes —
__construct(int|string $id)peut confondre le denormalizer. Préfère des types simples ou écris un DenormalizerInterface custom.Groupes oubliés = tout exposé — sans
groupsdans le contexte, tous les champs publics + ceux avec getters sont sérialisés. Risque de fuite (password,apiToken). Forcegroupspartout en prod.Cache invalidation — si tu modifies une annotation Serializer, le cache
var/cachedoit être vidé. En prod,cache:clearaprès deploy ; sinon, vieilles métadonnées en mémoire.
🧪 Testing
<?php
namespace App\Tests\Serializer;
use App\Dto\UserDto;
use App\Serializer\MoneyNormalizer;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Serializer\Serializer;
use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
final class UserDtoSerializationTest extends TestCase
{
public function testSerializeWithGroup(): void
{
$serializer = new Serializer(
[new MoneyNormalizer(), new ObjectNormalizer()],
[new JsonEncoder()],
);
$dto = new UserDto(1, '[email protected]', 'Jane Doe');
$json = $serializer->serialize($dto, 'json', ['groups' => ['user:list']]);
self::assertJsonStringEqualsJsonString(
'{"id":1,"email_address":"[email protected]"}',
$json,
);
}
}Pour des tests d'intégration, utilise KernelTestCase et self::getContainer()->get(SerializerInterface::class) pour avoir tous les normalizers tagués.
🎬 Cas d'usage concrets
Scénario 1 — Cabinet juridique : vue avocat vs vue client (Septeo, Cellence, Jarvis Legal)
Un cabinet d'avocats parisien utilise un DMS (Document Management System) maison où chaque Matter (dossier juridique) est exposé via une API REST consommée par : (1) le portail interne avocats, (2) le portail client. Le même endpoint /api/matters/{id} doit servir des payloads différents selon le rôle. L'avocat voit internalNotes, billingRate, conflictCheckResult, opposingCounselContact, tandis que le client voit summary, nextDeadline, documentsShared, invoiceAmount. Le Serializer Symfony résout ça via #[Groups] : matter:lawyer:read, matter:client:read, matter:billing:read. Un ContextBuilder détermine dynamiquement les groupes en fonction du rôle Security extrait du token (ROLE_LAWYER, ROLE_CLIENT_PORTAL). Le RGPD impose que les notes internes ne fuitent jamais côté client, donc un MaxDepth(2) et un #[Ignore] conditionnel sont posés. Les NameConverter (snake_case ↔ camelCase) sont activés car le frontend React utilise camelCase mais l'API documentée RGS suit snake_case. Le cabinet livre 4 portails clients/mois avec la même API en changeant uniquement le contexte de sérialisation.
Scénario 2 — Sérialisation API banque (Crédit Mutuel Arkéa, Société Générale Open API)
La banque expose son API DSP2/PSD2 publique avec des règles de masquage strictes : un IBAN complet ne doit jamais sortir vers un agrégateur non-AISP, seuls les 4 derniers chiffres. Les NormalizerInterface custom (MaskedIbanNormalizer) appliquent le masquage selon le scope OAuth2. Les groupes différencient account:public (4 derniers chiffres), account:aisp (IBAN complet, AISP consent valide), account:internal (toutes infos PCI DSS). Le CircularReferenceHandler est customisé pour éviter les boucles Account -> Transaction -> Account. Le DateTimeNormalizer impose ISO 8601 avec timezone (Europe/Paris). Les #[SerializedName] traduisent les propriétés PHP en noms exigés par la norme STET API : holderName → account_holder_name. Les tests valident chaque combinaison (scope OAuth2, groupe, format de sortie) — 80 cas combinatoires couverts par PHPUnit.
Scénario 3 — E-commerce prix selon rôle (Cdiscount Pro, ManoMano Pro, Mister-Auto B2B)
Sur une marketplace B2B/B2C, le même produit affiche un prix HT, TTC, ou avec remise négociée selon le profil. L'API /api/products/{id} retourne le Product sérialisé avec des groupes dynamiques : product:b2c (TTC simple), product:b2b (HT + TVA détaillée + écotaxes), product:b2b:contract-{id} (prix négocié contrat). Un NormalizerInterface custom (ContractualPriceNormalizer) appelle le moteur de pricing pour injecter negotiatedPrice uniquement si un contrat existe. Le DenormalizerInterface côté entrée valide les payloads de création de devis avec #[Context([AbstractObjectNormalizer::SKIP_NULL_VALUES => true])]. La sérialisation supporte application/json, application/xml (EDI fournisseurs), et text/csv (export comptable Sage) en branchant XmlEncoder et CsvEncoder. Le cache de sérialisation (PropertyAccessor + ClassMetadataFactory warmup) divise par 4 le temps de réponse sur les listings de 100 produits (12ms → 3ms).
🛠️ Exemple end-to-end
Cas : DMS cabinet juridique, même ressource Matter sérialisée différemment selon rôle (avocat / client / facturation).
<?php
// src/Entity/Matter.php
declare(strict_types=1);
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Attribute\Ignore;
use Symfony\Component\Serializer\Attribute\MaxDepth;
use Symfony\Component\Serializer\Attribute\SerializedName;
#[ORM\Entity]
class Matter
{
#[ORM\Id]
#[ORM\Column(type: 'uuid')]
#[Groups(['matter:lawyer:read', 'matter:client:read', 'matter:billing:read'])]
public string $id;
#[ORM\Column]
#[Groups(['matter:lawyer:read', 'matter:client:read'])]
#[SerializedName('reference')]
public string $clientReference;
#[ORM\Column(type: 'text')]
#[Groups(['matter:lawyer:read'])]
public string $internalNotes = '';
#[ORM\Column(type: 'text')]
#[Groups(['matter:client:read', 'matter:lawyer:read'])]
public string $summary;
#[ORM\Column(type: 'decimal', precision: 8, scale: 2)]
#[Groups(['matter:lawyer:read', 'matter:billing:read'])]
public string $billingRate;
#[ORM\Column]
#[Groups(['matter:client:read', 'matter:lawyer:read'])]
public \DateTimeImmutable $nextDeadline;
/** @var Document[] */
#[ORM\OneToMany(targetEntity: Document::class, mappedBy: 'matter')]
#[Groups(['matter:client:read', 'matter:lawyer:read'])]
#[MaxDepth(1)]
public array $documents = [];
#[ORM\ManyToOne(targetEntity: User::class)]
#[Groups(['matter:lawyer:read'])]
#[Ignore]
public ?User $assignedLawyer = null;
}<?php
// src/Serializer/MatterContextBuilder.php
declare(strict_types=1);
namespace App\Serializer;
use ApiPlatform\Serializer\SerializerContextBuilderInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Request;
final readonly class MatterContextBuilder implements SerializerContextBuilderInterface
{
public function __construct(
private SerializerContextBuilderInterface $decorated,
private Security $security,
) {}
public function createFromRequest(Request $request, bool $normalization, ?array $extractedAttributes = null): array
{
$context = $this->decorated->createFromRequest($request, $normalization, $extractedAttributes);
if (($context['resource_class'] ?? null) !== \App\Entity\Matter::class) {
return $context;
}
$groups = $context['groups'] ?? [];
if ($this->security->isGranted('ROLE_LAWYER')) {
$groups[] = 'matter:lawyer:read';
} elseif ($this->security->isGranted('ROLE_CLIENT_PORTAL')) {
$groups[] = 'matter:client:read';
}
if ($this->security->isGranted('ROLE_BILLING')) {
$groups[] = 'matter:billing:read';
}
$context['groups'] = array_unique($groups);
$context['enable_max_depth'] = true;
return $context;
}
}# config/services.yaml
services:
App\Serializer\MatterContextBuilder:
decorates: 'api_platform.serializer.context_builder'
arguments: ['@.inner', '@security.helper']<?php
// tests/Serializer/MatterSerializationTest.php
declare(strict_types=1);
namespace App\Tests\Serializer;
use App\Entity\Matter;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use Symfony\Component\Serializer\Serializer;
final class MatterSerializationTest extends TestCase
{
private Serializer $serializer;
protected function setUp(): void
{
$this->serializer = new Serializer([new ObjectNormalizer()], [new JsonEncoder()]);
}
public function testLawyerSeesInternalNotes(): void
{
$matter = $this->buildMatter();
$json = $this->serializer->serialize($matter, 'json', ['groups' => ['matter:lawyer:read']]);
$data = json_decode($json, true);
self::assertSame('Notes privées confidentielles', $data['internalNotes']);
self::assertArrayHasKey('billingRate', $data);
}
public function testClientNeverSeesInternalNotes(): void
{
$matter = $this->buildMatter();
$json = $this->serializer->serialize($matter, 'json', ['groups' => ['matter:client:read']]);
$data = json_decode($json, true);
self::assertArrayNotHasKey('internalNotes', $data);
self::assertArrayNotHasKey('billingRate', $data);
self::assertSame('Résumé public du dossier', $data['summary']);
}
private function buildMatter(): Matter
{
$matter = new Matter();
$matter->id = 'm-001';
$matter->clientReference = 'AF-2026-042';
$matter->internalNotes = 'Notes privées confidentielles';
$matter->summary = 'Résumé public du dossier';
$matter->billingRate = '350.00';
$matter->nextDeadline = new \DateTimeImmutable('2026-07-15');
return $matter;
}
}Couverture : entité multi-groupes + context builder dynamique selon rôle + tests d'isolation. Le même pattern s'applique à Invoice (vue compta vs vue client) et Client (vue commerciale vs vue paralegal).
🔁 Quand utiliser / éviter
Utiliser quand :
- Tu construis une API et veux des entrées/sorties typées.
- Tu as besoin de plusieurs formats (JSON + CSV pour export).
- Tu veux découpler le modèle interne du contrat externe (DTO + groupes).
Éviter quand :
- Performance extrême sur objets simples :
json_encode($array)est 10x plus rapide. Le Serializer brille sur les objets, pas les arrays. - Logique de transformation très métier : préfère un mapper explicite (
UserMapper::toDto($entity)) qui se lit comme du code, pas comme un graphe d'annotations. - Données très dynamiques (schema non fixe) :
json_decode($content, true)+ validation manuelle est plus simple.
🏋️ Exercices
Chaque exercice est autonome. Monte en difficulté : implémentation → production → casse-puis-répare.
1. MoneyNormalizer round-trip (implémentation)
Objectif : sérialiser/désérialiser un value object Money(amount: string, currency: string) avec round-trip exact (denormalize(normalize($m)) == $m). Indice/Solution : implémente NormalizerInterface + DenormalizerInterface + getSupportedTypes(): [Money::class => true]. Garde amount en string (jamais float — précision). Teste l'idempotence avec assertEquals($money, $serializer->denormalize($serializer->normalize($money), Money::class)).
2. Groupes par rôle sans fuite (production)
Objectif : un endpoint /api/users/{id} qui expose email, fullName à tous mais roles, lastLoginIp aux seuls ROLE_ADMIN, via un ContextBuilder (ou un #[Context] conditionnel). Écris un test par champ sensible prouvant qu'un non-admin ne le voit jamais. Indice/Solution : ContextBuilder qui ajoute user:admin si isGranted('ROLE_ADMIN'). Groupe par défaut = user:read (whitelist). Test : assertArrayNotHasKey('lastLoginIp', $clientPayload). Bonus : ajoute ALLOW_EXTRA_ATTRIBUTES => false côté write.
3. Casser un cycle infini (production)
Objectif : Author → Books → Author boucle et fait exploser la mémoire. Reproduis le CircularReferenceException, puis répare sans casser la profondeur utile. Indice/Solution : reproduis avec deux entités qui se référencent et groups couvrant les deux côtés. Répare via CIRCULAR_REFERENCE_HANDLER retournant l'IRI/ID (fn ($o) => $o->getId()), et MaxDepth(1) + enable_max_depth: true. Discute pourquoi des groupes disjoints sont souvent plus propres qu'un handler.
4. Cache de métadonnées — mesurer le gain (production)
Objectif : benchmarke la sérialisation de 5000 DTO avec et sans serializer.mapping.cache chaud. Prouve le ratio. Indice/Solution : construis deux Serializer — un avec ClassMetadataFactory + CacheClassMetadataFactory (PSR-6 ArrayAdapter pré-rempli), un sans. Mesure avec hrtime(true). Tu dois observer un facteur ≥ plusieurs× au premier passage. Ajoute getSupportedTypes() à un normalizer custom et re-mesure.
5. Mass-assignment exploit (casse-puis-répare)
Objectif : montre qu'un deserialize naïf permet à un client de s'auto-promouvoir admin ({"email":"x","roles":["ROLE_ADMIN"]}), puis ferme la faille. Indice/Solution : prouve l'exploit avec un groupe d'écriture trop large incluant roles. Répare : DTO d'input dédié sans champ privilégié + ALLOW_EXTRA_ATTRIBUTES => false → le payload malveillant lève une exception. Écris le test rouge-puis-vert.
6. Denormalize tolérant + RFC 7807 (production-grade)
Objectif : transformer un payload partiellement invalide (3 champs au mauvais type) en une réponse 422 application/problem+json listant les 3 erreurs d'un coup. Indice/Solution : COLLECT_DENORMALIZATION_ERRORS => true, catch PartialDenormalizationException, mappe getErrors() (path + expected types) vers un violations[]. Compare à l'approche naïve qui ne remonte que la première erreur (frustrant côté client form).
🎤 En entretien
Q : Quelle est la différence entre un Normalizer et un Encoder, et pourquoi cette séparation ? R : Le Normalizer fait objet ↔ array (logique métier, groupes, types), l'Encoder fait array ↔ string (syntaxe du format). La séparation permet de combiner N normalizers × M formats sans explosion combinatoire : un seul DateTimeNormalizer sert JSON, XML et CSV.
Q : Pourquoi getSupportedTypes() a été ajouté en 6.3 et que se passe-t-il sans ? R : C'est un hint de cacheabilité. Sans lui, supportsNormalization() est rappelé pour chaque champ de chaque objet (O(champs×normalizers)). Avec, le framework mémoïse la résolution par type. Retourne [Type => true] si le support ne dépend que du type, false (ou null) si la décision dépend de la valeur runtime et ne peut être cachée.
Q : Le Serializer fait-il partie de ta surface de sécurité ? R : Oui, à deux endroits. En sortie, les #[Groups] décident quels champs fuient — une erreur de groupe = fuite de données (whitelist + tests d'isolation obligatoires). En entrée, deserialize est un vecteur de mass-assignment ; on isole les champs privilégiés (DTO d'input, ALLOW_EXTRA_ATTRIBUTES => false) et on ne laisse jamais le client choisir le type cible.
Q : Entité annotée ou DTO ? Comment tu tranches ? R : DTO dès qu'il y a >1 consommateur, un besoin de versionnement, ou un risque que le schéma DB évolue indépendamment du contrat. L'entité est un détail de persistance ; le DTO est le contrat. Le coût du mapping est borné et explicite ; le coût d'un champ DB qui fuit ou d'un N+1 par lazy-loading en sérialisation est non borné.
🔗 Liens
- Doc : https://symfony.com/doc/current/components/serializer.html
- Normalizers built-in : https://symfony.com/doc/current/serializer/normalizers.html
- Encoders : https://symfony.com/doc/current/serializer/encoders.html
getSupportedTypes: https://symfony.com/blog/new-in-symfony-6-3-performance-improvements- Migration 6 → 7 : https://github.com/symfony/symfony/blob/7.0/UPGRADE-7.0.md#serializer