← Tous les cours / Symfony Serializer avancé Cours
00 — LE PIPELINE

Le pipeline serialize/normalize/encode

Serializer est une pile en 3 couches — normalizer (objet → tableau), encoder (tableau → string). Désérialiser c'est l'inverse.

Le Serializer Symfony n'est pas une boîte noire — c'est une pile à trois couches empilées dans l'ordre. Comprendre cette pile, c'est comprendre pourquoi chaque option se configure où elle se configure.

serialize()
Appelle d'abord normalize() pour obtenir un tableau PHP (ou un scalaire), puis encode() pour convertir ce tableau en string. C'est le point d'entrée de haut niveau que tu utilises dans tes controllers.
deserialize()
Appelle d'abord decode() pour obtenir un tableau depuis la string, puis denormalize() pour reconstruire l'objet PHP. L'inverse exact de serialize().

Les trois couches

Objet PHP
Normalizer
objet → tableau
Encoder
tableau → string
String JSON/XML…

↕ inverse

String JSON/XML…
Decoder
string → tableau
Denormalizer
tableau → objet
Objet PHP

Les interfaces

InterfaceMéthodes clésRôle
SerializerInterface serialize($data, $format, $context)
deserialize($data, $type, $format, $context)
Point d'entrée haut niveau
NormalizerInterface normalize($data, $format, $context)
supportsNormalization()
Objet → tableau/scalaire
DenormalizerInterface denormalize($data, $type, $format, $context)
supportsDenormalization()
Tableau → objet
EncoderInterface encode($data, $format, $context)
supportsEncoding($format)
Tableau → string
DecoderInterface decode($data, $format, $context)
supportsDecoding($format)
String → tableau

Formats disponibles

Symfony fournit quatre encoders prêts à l'emploi : json (JsonEncoder), xml (XmlEncoder), csv (CsvEncoder), yaml (YamlEncoder, requiert symfony/yaml).

Lance la simulation pour voir les étapes s'exécuter dans l'ordre :

Clique sur Sérialiser pour visualiser les étapes.

01 — NORMALIZERINTERFACE

NormalizerInterface et first-match

Un normalizer transforme un objet PHP en données normalisées — il est sélectionné par le premier supportsNormalization() vrai.

Quand tu appelles $serializer->normalize($monObjet), le Serializer parcourt sa liste de normalizers dans l'ordre et interroge chacun : "Est-ce que tu sais normaliser cet objet dans ce format ?" Le premier qui répond true est utilisé — c'est la first-match strategy.

NormalizerInterface
Deux méthodes obligatoires : normalize(mixed $data, ?string $format, array $context): array|string|int|float|bool|\ArrayObject|null et supportsNormalization(mixed $data, ?string $format, array $context): bool. La troisième méthode, getSupportedTypes(?string $format): array, est optionnelle mais recommandée pour le cache de support.

getSupportedTypes() — le cache de support

Depuis Symfony 6.3, getSupportedTypes() permet au Serializer de mémoriser quels types un normalizer supporte, sans appeler supportsNormalization() à chaque fois :

public function getSupportedTypes(?string $format): array
{
    return [
        User::class       => true,  // toujours supporté, résultat mis en cache
        Product::class    => true,
        'object'           => false, // supporte les objets, mais résultat PAS mis en cache
        '*'                => false, // wildcard : supporte tout, sans cache
    ];
}
💡 Valeur true vs false : true signifie "ce normalizer supporte ce type et le résultat de supportsNormalization() peut être mis en cache". false signifie "supporte ce type mais il faut appeler supportsNormalization() à chaque fois" (utile quand la réponse dépend du contexte).

Ordre de priorité

L'ordre dans lequel les normalizers sont consultés est déterminé par le tag serializer.normalizer avec l'attribut priority. Un priority plus élevé est consulté en premier.

# config/services.yaml
App\Serializer\MonNormalizer:
    tags:
        - { name: serializer.normalizer, priority: 200 }
# ObjectNormalizer a une priorité de -1000 : il est le dernier recours

DenormalizerInterface

L'interface miroir pour la dénormalisation, avec deux constantes importantes pour la collecte des erreurs :

use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;

// Collecter toutes les erreurs de type au lieu de lever une exception au premier
$data = $serializer->deserialize($json, User::class, 'json', [
    DenormalizerInterface::COLLECT_DENORMALIZATION_ERRORS => true,
]);

// Collecter les attributs en trop (non présents dans la classe cible)
$data = $serializer->deserialize($json, User::class, 'json', [
    DenormalizerInterface::COLLECT_EXTRA_ATTRIBUTES_ERRORS => true,
]);
🔑 COLLECT_DENORMALIZATION_ERRORS : quand activée, la dénormalisation ne s'arrête pas au premier problème de type. Toutes les erreurs sont collectées et retournées dans une exception PartialDenormalizationException, dont tu peux extraire la liste via $e->getErrors().
02 — NORMALIZERS FOURNIS

Les normalizers fournis par Symfony

Symfony fournit une bibliothèque de normalizers spécialisés — chacun cible un type PHP précis.

Plutôt que de tout implémenter depuis zéro, commence toujours par vérifier si un normalizer fourni couvre ton besoin. Symfony en fournit une douzaine — chacun est spécialisé sur un type PHP précis.

ObjectNormalizer — le couteau suisse

ObjectNormalizer est le normalizer le plus polyvalent. Il utilise le composant PropertyAccess pour lire les propriétés : getters (getName()), propriétés publiques, accès is/has… dans cet ordre. C'est lui qui est utilisé par défaut pour les entités Doctrine et les DTOs.

// Symfony configure automatiquement ObjectNormalizer avec PropertyAccess.
// Pour une classe sans getter :
class User {
    public int $id;
    public string $name;
}
// → normalize() retourne ['id' => 42, 'name' => 'Alice']

GetSetMethodNormalizer vs PropertyNormalizer

NormalizerStratégie d'accèsCas d'usage
GetSetMethodNormalizer Getters/setters uniquement (getName() → clé name) Classes Java-style avec accesseurs explicites
PropertyNormalizer Propriétés (publiques + protected + private via reflection) Value objects avec propriétés privées directes
ObjectNormalizer PropertyAccess (getters, puis props publiques) Cas général — choix par défaut

Normalizers spécialisés

DateTimeNormalizer
Normalise \DateTimeInterface en string et inversement. Options : datetime_format (ex. 'Y-m-d\TH:i:sP'), datetime_timezone (DateTimeZone). Par défaut, utilise RFC 3339.
BackedEnumNormalizer
Normalise les backed enums PHP 8.1 (Status::Active'active'). Dénormalise la valeur brute vers l'enum ('active'Status::Active). Lève InvalidArgumentException si la valeur n'existe pas dans l'enum.
UidNormalizer
Normalise les AbstractUid Symfony (UUID, ULID). Option uid_normalization_format : 'canonical' (RFC 4122), 'base32', 'base58', 'rfc4122'.
ArrayDenormalizer
Dénormalise T[] depuis un tableau de tableaux. Usage : passer User[]::class comme type dans deserialize(). Ce normalizer est automatiquement consulté dès que le type se termine par [].
JsonSerializableNormalizer
Délègue à jsonSerialize() pour les classes qui implémentent JsonSerializable. Utile pour les classes qui gèrent elles-mêmes leur représentation JSON (ex. Money, Collection maison).

Guide de choix

Type PHPNormalizer à utiliser
Entité Doctrine / DTO avec gettersObjectNormalizer (défaut)
\DateTimeInterfaceDateTimeNormalizer
Backed enum PHP 8.1BackedEnumNormalizer
UUID / ULID SymfonyUidNormalizer
Tableau d'objets T[]ArrayDenormalizer
Classe implémentant JsonSerializableJsonSerializableNormalizer
Violation de contrainte ValidatorConstraintViolationListNormalizer
03 — ENCODERS

Encoders — JSON, XML, CSV, YAML

Un encoder transforme les données normalisées en string — chaque format a ses options contextuelles.

Un encoder ne voit que les données normalisées — un tableau PHP ou un scalaire. Il n'a aucune connaissance des objets. C'est pourquoi les options de format se passent toutes dans le contexte, non dans la configuration du service.

JsonEncoder

$serializer->serialize($data, 'json', [
    'json_encode_options'   => JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE,
    'json_decode_associative' => false,        // decode en stdClass au lieu de tableau
    'json_decode_recursion_depth' => 512,     // profondeur max de décodage
]);
json_encode_options
Accepte les flags PHP json_encode() combinés avec |. Flags utiles : JSON_PRETTY_PRINT, JSON_UNESCAPED_UNICODE, JSON_UNESCAPED_SLASHES, JSON_THROW_ON_ERROR (déjà activé par défaut dans Symfony 6+), JSON_PRESERVE_ZERO_FRACTION.

XmlEncoder

$serializer->serialize($data, 'xml', [
    'xml_root_node_name'   => 'response',   // nœud racine (défaut: 'response')
    'xml_format_output'    => true,           // indenter le XML
    'xml_remove_empty_tags' => true,          // supprimer les balises vides
    'xml_encoding'         => 'UTF-8',       // encodage déclaré dans 
    'xml_version'          => '1.0',
]);
⚠️ Attributs XML : pour générer <item id="1">, préfixe les clés de ton tableau avec @ : ['@id' => 1, '#' => 'contenu']. Le # est la clé du nœud texte.

CsvEncoder

$serializer->serialize($rows, 'csv', [
    'csv_delimiter'    => ';',             // séparateur (défaut: ',')
    'csv_enclosure'    => '"',             // caractère d'encadrement
    'csv_headers'      => ['id', 'name'], // colonnes dans l'ordre
    'no_headers'       => false,           // omettre la ligne d'en-tête
    'csv_key_separator' => '.',           // séparateur pour tableaux imbriqués
]);
Attention aux tableaux imbriqués en CSV
Le CSV est plat par nature. Si tu as un objet avec des sous-objets, le CsvEncoder aplatit les clés avec le csv_key_separator : un objet { user: { name: 'Alice' } } devient la colonne user.name.

YamlEncoder

$serializer->serialize($data, 'yaml', [
    'yaml_inline'  => 2,  // niveau à partir duquel on passe en style inline
    'yaml_indent'  => 4,  // indentation
    'yaml_flags'   => 0,  // flags Yaml::dump()
]);

Le YamlEncoder requiert le composant symfony/yaml. Il est enregistré automatiquement si le composant est installé.

04 — ATTRIBUTS PHP 8

Attributs de sérialisation

Les attributs PHP 8 déclarent directement sur la classe comment chaque propriété doit être sérialisée.

Plutôt que de configurer les normalizers dans des fichiers YAML séparés, les attributs PHP 8 permettent de déclarer la sérialisation directement sur la classe. C'est la façon recommandée depuis Symfony 6.

#[Groups]

use Symfony\Component\Serializer\Attribute\Groups;

class User
{
    #[Groups(['public', 'admin'])]
    public int $id;

    #[Groups(['public'])]
    public string $name;

    #[Groups(['admin'])]
    public string $email;
}

#[SerializedName]

Renomme la clé dans la sortie sérialisée, sans toucher au nom de la propriété PHP :

#[SerializedName('first_name')]
public string $firstName;
// → {"first_name": "Alice"} au lieu de {"firstName": "Alice"}

#[Ignore]

Exclut complètement une propriété de la sérialisation et de la dénormalisation :

#[Ignore]
private string $password;
// → la propriété n'apparaît jamais dans la sortie JSON

#[MaxDepth]

Limite la profondeur de sérialisation pour une propriété d'objet imbriqué. Requiert enable_max_depth => true dans le contexte :

#[MaxDepth(1)]
public Category $category;
// → la Category est sérialisée, mais ses sous-catégories non

#[Context]

Surcharge le contexte global pour une propriété spécifique. Utile pour des formats de date différents selon les propriétés :

#[Context(normalizationContext: ['datetime_format' => 'd/m/Y'])]
public \DateTimeImmutable $birthdate;

#[Context(normalizationContext: ['datetime_format' => 'Y-m-d\TH:i:sP'])]
public \DateTimeImmutable $createdAt;

#[SerializedPath]

Mappe une propriété vers un chemin imbriqué dans la sortie — ou désimbrique lors de la dénormalisation :

#[SerializedPath('[owner][name]')]
public string $ownerName;
// → {"owner": {"name": "Alice"}} au lieu de {"ownerName": "Alice"}
🔑 Namespace des attributs : depuis Symfony 6.4, les attributs sont dans Symfony\Component\Serializer\Attribute\ (sans le Annotation). L'ancien namespace Symfony\Component\Serializer\Annotation\ reste valide pour la compatibilité, mais préfère le nouveau.

Active ou désactive les attributs ci-dessous pour voir comment la sortie JSON évolue :

Sortie JSON résultante :


  
05 — GROUPES

Groupes de sérialisation

Les groupes permettent de contrôler quelles propriétés sont sérialisées selon le contexte — API publique vs admin vs interne.

Imagine une entité User avec 10 propriétés. Ton API publique en expose 3. Ton back-office en expose 8. Tes logs internes en consomment 2. Les groupes de sérialisation te permettent de gérer ces trois vues sans dupliquer la classe — juste en passant le bon groupe dans le contexte.

Déclarer les groupes sur les propriétés

use Symfony\Component\Serializer\Attribute\Groups;

class User
{
    #[Groups(['public', 'admin'])]
    public int $id;

    #[Groups(['public'])]
    public string $name;

    #[Groups(['admin'])]
    public string $email;

    #[Groups(['internal'])]
    public string $passwordHash;
    // pas de #[Groups] → sérialisée uniquement si aucun groupe dans le contexte
    public \DateTimeImmutable $createdAt;
}

Passer les groupes dans le contexte

// API publique — expose id + name
$serializer->serialize($user, 'json', ['groups' => ['public']]);

// Back-office — expose id + name + email
$serializer->serialize($user, 'json', ['groups' => ['public', 'admin']]);

// Aucun groupe → TOUTES les propriétés (y compris sans #[Groups])
$serializer->serialize($user, 'json');
Règle de base
Dès qu'un groupe est spécifié dans le contexte, seules les propriétés portant #[Groups] avec au moins un groupe correspondant sont sérialisées. Les propriétés sans #[Groups] sont ignorées. En l'absence de tout groupe dans le contexte, toutes les propriétés sont sérialisées.

normalizationContext vs denormalizationContext

Dans les controllers Symfony, tu peux différencier les groupes selon la direction :

// Avec #[Context] au niveau de la propriété :
#[Context(
    normalizationContext:   ['groups' => ['public']],
    denormalizationContext: ['groups' => ['admin']],
)]
public string $role;
💡 Convention recommandée : nomme tes groupes en snake_case et préfixe-les par le contexte métier (api:public, api:admin, webhook:payload). Ça évite les collisions entre bundles et rend le code auto-documenté.

Coche les groupes actifs pour voir quelles propriétés sont incluses dans la sortie :

Propriétés incluses :

06 — CONTEXTE

Contexte — les clés importantes

Le contexte est un tableau passé à chaque couche du serializer — il contrôle le comportement fin de chaque normalizer.

Le contexte est le troisième argument de serialize() / normalize(). C'est un simple tableau PHP transmis à toutes les couches de la pile. Chaque normalizer et encoder lit les clés qui le concernent et ignore les autres.

Gestion des valeurs nulles et non-initialisées

// Omettre les propriétés nulles
$serializer->serialize($user, 'json', [
    'skip_null_values' => true,
]);
// {"id":42,"name":"Alice"} — 'email' null omis

// Omettre les propriétés PHP non initialisées (PHP 8.0+)
$serializer->serialize($dto, 'json', [
    'skip_uninitialized_values' => true,
]);

// Garder {} pour les objets vides (au lieu de [])
$serializer->serialize($data, 'json', [
    'preserve_empty_objects' => true,
]);

object_to_populate — mise à jour partielle

Lors de la dénormalisation, tu peux cibler un objet existant à mettre à jour au lieu d'en créer un nouveau. Idéal pour les endpoints PATCH :

$existingUser = $repository->find($id);

$updatedUser = $serializer->deserialize($json, User::class, 'json', [
    'object_to_populate' => $existingUser,
]);
// $existingUser est modifié en place — aucune nouvelle instance créée

callbacks — transformer une propriété à la volée

Un callback reçoit la valeur normalisée et peut la transformer. Utile sans avoir besoin d'un normalizer custom :

$serializer->serialize($user, 'json', [
    'callbacks' => [
        'birthdate' => fn(\DateTimeInterface $d) => $d->format('d/m/Y'),
        'tags'      => fn(array $tags) => implode(', ', $tags),
    ],
]);
💡 callbacks vs normalizer custom : un callback est pratique pour une transformation ponctuelle dans un endpoint spécifique. Un normalizer custom s'impose dès que la transformation est réutilisée dans plusieurs contextes ou dépend d'autres services.

ignored_attributes et allow_extra_attributes

// Exclure des propriétés sans modifier la classe
$serializer->serialize($user, 'json', [
    'ignored_attributes' => ['password', 'internalNotes'],
]);

// Désactiver le rejet des attributs inconnus lors de la dénormalisation
// (par défaut : true — les attributs inconnus sont ignorés silencieusement)
$serializer->deserialize($json, User::class, 'json', [
    'allow_extra_attributes' => false, // lève une exception si attribut inconnu
]);

disable_type_enforcement

// Désactiver la vérification de type lors de la dénormalisation
// Utile pour les formulaires où les types arrivent en string
$serializer->deserialize($formData, User::class, 'json', [
    'disable_type_enforcement' => true,
]);
🔑 Contexte global vs contexte par requête : dans les controllers Symfony, tu passes le contexte à chaque appel. Pour un contexte global (ex. toujours skip_null_values), configure-le dans framework.yaml : serializer: default_context: { skip_null_values: true }.
07 — RÉFÉRENCES CIRCULAIRES

Références circulaires et MaxDepth

Quand un objet se référence lui-même, le serializer boucle — deux mécanismes existent pour gérer ça.

Tu as un objet Category avec une propriété $parent qui est aussi une Category, elle-même avec un $parent… et ainsi de suite. Le Serializer va boucler jusqu'à une CircularReferenceException — à moins que tu ne configures l'un des deux mécanismes de protection.

circular_reference_handler

Un callable appelé dès qu'une référence circulaire est détectée. La valeur retournée remplace l'objet dans la sortie :

$serializer->serialize($category, 'json', [
    'circular_reference_handler' => fn(object $obj) => $obj->getId(),
]);
// La Category parente est remplacée par son ID dans la sortie
circular_reference_limit
Nombre de fois qu'un même objet peut être sérialisé dans le même arbre avant que le handler soit déclenché. Défaut : 1. Augmenter à 2 pour les structures qui se réfèrent légitimement à elles-mêmes une fois (ex. arbre binaire avec nœud gauche = nœud courant dans certains algorithmes).
$serializer->serialize($data, 'json', [
    'circular_reference_limit'   => 2,
    'circular_reference_handler' => fn(object $obj) => $obj->getId(),
]);

#[MaxDepth] — tronquer à N niveaux

#[MaxDepth] ne résout pas une boucle infinie — il la prévient en limitant la profondeur de sérialisation à N niveaux pour une propriété donnée. Il faut activer enable_max_depth dans le contexte :

use Symfony\Component\Serializer\Attribute\MaxDepth;

class Category
{
    public string $name;

    #[MaxDepth(2)]
    public ?Category $parent = null;
}

$serializer->serialize($category, 'json', [
    'enable_max_depth' => true,
]);

max_depth_handler — contrôler la valeur tronquée

Par défaut, quand la profondeur max est atteinte, la valeur est null. Tu peux retourner autre chose via max_depth_handler :

$serializer->serialize($category, 'json', [
    'enable_max_depth' => true,
    'max_depth_handler' => function(object $obj, string $prop, array $context): mixed {
        return ['id' => $obj->getId(), '_truncated' => true];
    },
]);
circular_reference_handler
  • Détecte une boucle infinie à l'exécution
  • Déclenché quand l'objet A → B → A est détecté
  • La valeur retournée remplace l'objet circulaire
  • Fonctionne sans modifier les entités
#[MaxDepth]
  • Limite déclarée statiquement sur la propriété
  • Tronque à N niveaux, qu'il y ait boucle ou non
  • Requiert enable_max_depth dans le contexte
  • Contrôle plus fin via max_depth_handler
💡 Bonne pratique : combine les deux. Utilise #[MaxDepth(2)] sur les relations potentiellement profondes pour éviter les sérialisations accidentellement longues, et configure circular_reference_handler comme filet de sécurité.
08 — CUSTOM NORMALIZER

Custom normalizer et DiscriminatorMap

Quand aucun normalizer fourni ne convient, on en crée un — et pour la désérialisation polymorphique, DiscriminatorMap câble tout.

Quand aucun normalizer fourni ne couvre ton besoin — type complexe, logique métier dans la transformation, injection de service — tu crées le tien en quelques dizaines de lignes.

Structure d'un normalizer custom

use Symfony\Component\Serializer\Normalizer\NormalizerInterface;

final class MoneyNormalizer implements NormalizerInterface
{
    public function normalize(mixed $data, ?string $format = null, array $context = []): array
    {
        /** @var Money $data */
        return [
            'amount'   => $data->getAmount(),
            'currency' => $data->getCurrency()->getCode(),
        ];
    }

    public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool
    {
        return $data instanceof Money;
    }

    public function getSupportedTypes(?string $format): array
    {
        // true = résultat de supportsNormalization() mis en cache pour ce type
        return [Money::class => true];
    }
}
Tag automatique
Symfony autoconfigure les services implémentant NormalizerInterface avec le tag serializer.normalizer. Tu n'as rien à déclarer dans services.yaml. Pour contrôler la priorité : #[AsTaggedItem(priority: 100)] sur la classe.

NormalizerAwareInterface — déléguer les sous-objets

Si l'objet que tu normalises contient lui-même des sous-objets, injecte le serializer pour les déléguer :

use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;

final class OrderNormalizer implements NormalizerInterface, NormalizerAwareInterface
{
    use NormalizerAwareTrait;

    public function normalize(mixed $data, ?string $format = null, array $context = []): array
    {
        return [
            'id'    => $data->getId(),
            // Déléguer les lignes de commande au normalizer approprié
            'lines' => array_map(
                fn($line) => $this->normalizer->normalize($line, $format, $context),
                $data->getLines()->toArray()
            ),
        ];
    }
}

#[DiscriminatorMap] — désérialisation polymorphique

Quand un champ JSON peut représenter plusieurs types PHP différents (par exemple un shape qui peut être un Circle ou un Square), #[DiscriminatorMap] câble automatiquement la résolution :

use Symfony\Component\Serializer\Attribute\DiscriminatorMap;

#[DiscriminatorMap(
    typeProperty: 'type',
    mapping: [
        'circle'  => Circle::class,
        'square'  => Square::class,
        'triangle' => Triangle::class,
    ]
)]
abstract class Shape {}

final class Circle extends Shape
{
    public function __construct(public readonly float $radius) {}
}

// Désérialisation :
$shape = $serializer->deserialize(
    '{"type":"circle","radius":5.0}',
    Shape::class,
    'json'
);
// → instance de Circle avec $radius = 5.0
typeProperty
La clé JSON qui contient le discriminant ("type" dans l'exemple). Cette clé est lue avant la dénormalisation — elle n'apparaît pas nécessairement comme propriété dans la classe cible.
💡 defaultType : passe defaultType: SomeClass::class pour gérer les entrées dont la valeur du discriminant n'est pas dans le mapping, au lieu de lever une exception. Utile pour les migrations de format où d'anciens documents n'ont pas encore le champ type.
09 — SYNTHÈSE

Synthèse — le guide de choix

Le guide de choix et les anti-patterns du Serializer Symfony.

Le Serializer Symfony est un système extensible avec de nombreuses options. Voici les règles de décision et les erreurs classiques à éviter.

Guide de choix — quel normalizer ?

Type PHP à normalizerNormalizer recommandé
Entité Doctrine, DTO avec getters/props publiques ObjectNormalizer (défaut automatique)
\DateTimeInterface / \DateTimeImmutable DateTimeNormalizer
Backed enum PHP 8.1+ BackedEnumNormalizer
UUID / ULID Symfony UidNormalizer
Tableau d'objets User[] ArrayDenormalizer (automatique pour T[])
Classe implémentant JsonSerializable JsonSerializableNormalizer
ConstraintViolationList du Validator ConstraintViolationListNormalizer
Type custom ou logique complexe Custom normalizer (implements NormalizerInterface)
Hiérarchie polymorphique ObjectNormalizer + #[DiscriminatorMap]

Anti-patterns à éviter

🚫 Utiliser json_encode() directement sur une entité Doctrine.
Les entités ont des proxies Doctrine, des collections lazy, des relations circulaires potentielles. Passer par le Serializer est la seule façon correcte : il gère les groupes, les profondeurs et les types complexes.
🚫 Oublier getSupportedTypes() dans un normalizer custom.
Sans cette méthode, supportsNormalization() est appelée à chaque sérialisation pour chaque objet. Avec elle, le résultat est mis en cache dès le premier appel. C'est une ligne de code pour un gain de performance mesurable.
🚫 Chaîner plusieurs normalizers sans priorité explicite.
L'ordre des normalizers dans le DIC sans priorité dépend de l'ordre de chargement des services. C'est non déterministe entre environnements. Toujours déclarer une priority sur le tag serializer.normalizer de tes normalizers custom.
🚫 Activer enable_max_depth sans #[MaxDepth] sur les propriétés.
enable_max_depth sans attributs ne fait rien — la profondeur n'est pas limitée par défaut. Il faut les deux : l'option dans le contexte ET l'attribut sur la propriété concernée.

Récapitulatif des attributs

AttributCible PHPRôle
#[Groups(string|array)]Propriété, méthodeAppartenance à des groupes de sérialisation
#[SerializedName(string)]Propriété, méthodeRenommer la clé dans la sortie
#[Ignore]Propriété, méthodeExclure complètement de la sérialisation
#[MaxDepth(int)]PropriétéLimiter la profondeur de sérialisation
#[Context(...)]Propriété, méthodeContexte spécifique à une propriété
#[SerializedPath(string)]PropriétéMapper vers/depuis un chemin imbriqué
#[DiscriminatorMap(typeProperty, mapping)]Classe abstraiteDésérialisation polymorphique