Efficience IT
·Outils

Migrer du Serializer vers JsonStreamer : le guide honnête

Par Louis-Arnaud Catoire

Migrer du Serializer vers JsonStreamer : le guide honnête

JsonStreamer sérialise jusqu'à 6x plus vite. Voilà pourquoi tu ne peux pas juste remplacer ton Serializer demain.

Le nouveau composant symfony/json-streamer, introduit dans Symfony 7.3 comme composant expérimental, promet des gains massifs sur la sérialisation JSON. Mais entre un benchmark sur un DTO simple et ton application Symfony en production avec ses groupes de normalisation, ses callbacks custom et ses objets imbriqués sur trois niveaux, il y a un gouffre.

Un point important : le composant est marqué expérimental par Symfony. Son API peut changer entre deux versions mineures sans garantie de rétrocompatibilité. C'est à prendre en compte avant de l'utiliser en production.

Cet article répond à trois questions : est-ce que mon projet est éligible ? Qu'est-ce que je vais perdre ? Par où commencer ?

Ce que JsonStreamer change vraiment

Le Serializer Symfony fonctionne par réflexion. À chaque appel, il inspecte l'objet, résout les métadonnées, applique les normalizers dans l'ordre, construit un tableau PHP intermédiaire, puis l'encode en JSON. Tout est en mémoire, tout est dynamique, tout est flexible.

JsonStreamer prend l'approche inverse. Au moment du build (ou au premier appel), il génère du code PHP natif spécialisé pour chaque classe. Ce code produit du JSON directement, sans tableau intermédiaire, via un générateur PHP. C'est la différence entre imprimer un livre entier avant de l'envoyer, et l'envoyer page par page au fur et à mesure de l'impression. Le destinataire commence à recevoir alors que l'impression n'est pas terminée.

Les benchmarks officiels Symfony annoncent des gains jusqu'à 10x en temps et 98% en mémoire. On a voulu vérifier. Voici notre protocole : un DTO simple (ProductDto, cinq propriétés scalaires et une date), sérialisé en JSON sur des collections de 1 000 à 50 000 objets, dans un conteneur Docker php:8.4-cli vanilla.

ObjetsSerializer (ms)JsonStreamer (ms)Gain tempsSerializer (MB)JsonStreamer (MB)Gain mémoire
1 0001772.5x< 1< 1-
10 00063154x4< 1> 75%
50 000342546.3x24867%

Mesures sur PHP 8.4.8, conteneur Docker php:8.4-cli sur Windows 11 (WSL2), moyenne de deux runs.

Les chiffres sont en dessous des 10x annoncés, mais le gain reste significatif et augmente avec le volume. Sur 50 000 objets, le Serializer met 6 fois plus de temps et consomme 3 fois plus de mémoire. Sur des collections plus larges ou des objets plus complexes, l'écart se creuse encore.

Le script de benchmark complet est disponible ci-dessous pour reproduire ces mesures sur votre propre machine :

<?php

require __DIR__ . '/vendor/autoload.php';

use Symfony\Component\Serializer\Serializer;
use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use Symfony\Component\JsonStreamer\JsonStreamWriter;
use Symfony\Component\JsonStreamer\Attribute\JsonStreamable;
use Symfony\Component\TypeInfo\Type;

#[JsonStreamable]
class ProductDto
{
    public int $id;
    public string $name;
    public float $price;
    public string $category;
    public string $createdAt;
}

function generateProducts(int $count): array
{
    $products = [];
    for ($i = 0; $i < $count; $i++) {
        $dto = new ProductDto();
        $dto->id = $i;
        $dto->name = "Produit $i";
        $dto->price = round(mt_rand(100, 99999) / 100, 2);
        $dto->category = ['PHP', 'Symfony', 'DevOps', 'Cloud'][$i % 4];
        $dto->createdAt = '2026-01-' . str_pad(($i % 28) + 1, 2, '0', STR_PAD_LEFT);
        $products[] = $dto;
    }
    return $products;
}

foreach ([1_000, 10_000, 50_000] as $count) {
    $products = generateProducts($count);

    $serializer = new Serializer([new ObjectNormalizer()], [new JsonEncoder()]);
    memory_reset_peak_usage();
    $start = hrtime(true);
    $serializer->serialize($products, 'json');
    $serMs = round((hrtime(true) - $start) / 1_000_000, 1);
    $serMb = round(memory_get_peak_usage(true) / 1024 / 1024, 1);

    $streamer = JsonStreamWriter::create(streamWritersDir: '/tmp/cache');
    memory_reset_peak_usage();
    $start = hrtime(true);
    foreach ($streamer->write($products, Type::list(Type::object(ProductDto::class))) as $chunk) {}
    $strMs = round((hrtime(true) - $start) / 1_000_000, 1);
    $strMb = round(memory_get_peak_usage(true) / 1024 / 1024, 1);

    echo "$count objets : Serializer {$serMs}ms / {$serMb}MB, ";
    echo "JsonStreamer {$strMs}ms / {$strMb}MB\n";
}

Pour le lancer sous Linux/macOS :

docker run --rm -v $(pwd):/app -w /app php:8.4-cli \
  bash -c "apt-get update -qq > /dev/null && apt-get install -y -qq unzip git > /dev/null \
  && curl -sS https://getcomposer.org/installer | php -- --quiet \
  && php composer.phar require symfony/serializer symfony/json-streamer symfony/property-access symfony/property-info \
  && php bench.php"

Sous Windows (Git Bash / PowerShell) :

docker run --rm -v "%cd%:/app" -w /app php:8.4-cli bash -c "apt-get update -qq > /dev/null && apt-get install -y -qq unzip git > /dev/null && curl -sS https://getcomposer.org/installer | php -- --quiet && php composer.phar require symfony/serializer symfony/json-streamer symfony/property-access symfony/property-info && php bench.php"

Les prérequis : la checklist avant de commencer

PHP 8.4 obligatoire

Le composant requiert PHP 8.4 minimum ("php": ">=8.4" dans son composer.json). Pas de contournement possible.

php -v | head -1

Si la sortie affiche PHP 8.2 ou 8.3 : stop. Rien d'autre à lire ici, planifie d'abord ta montée de version PHP. Si tu es en 8.4+, on continue.

Audit de tes objets sérialisés

JsonStreamer n'accepte que des classes à propriétés publiques, sans logique dans le constructeur. Un DTO pur, typiquement. Le script suivant analyse ton répertoire src/ et identifie les classes compatibles :

<?php

$directory = new RecursiveDirectoryIterator('src/');
$iterator = new RecursiveIteratorIterator($directory);
$phpFiles = new RegexIterator($iterator, '/\.php$/');

foreach ($phpFiles as $file) {
    $content = file_get_contents($file->getPathname());
    if (!preg_match('/class\s+(\w+)/', $content, $matches)) {
        continue;
    }

    $className = $matches[1];
    $issues = [];

    if (preg_match('/function\s+__construct\s*\([^)]+\)/', $content)) {
        $issues[] = 'constructeur avec arguments';
    }

    if (preg_match_all('/\b(private|protected)\s+\$/', $content, $propMatches)) {
        $issues[] = count($propMatches[0]) . ' propriétés non publiques';
    }

    $status = empty($issues) ? 'COMPATIBLE' : 'INCOMPATIBLE';
    $reason = empty($issues) ? '' : ' (' . implode(', ', $issues) . ')';

    echo sprintf("[%s] %s%s\n", $status, $className, $reason);
}

Sur un projet Symfony typique, ne sois pas surpris si 70% de tes classes sont incompatibles. C'est normal, les entités Doctrine, les services, les value objects avec constructeur sont tous exclus. Seuls tes DTOs purs sont candidats.

Inventaire des fonctionnalités Serializer utilisées

Avant de migrer un endpoint, vérifie quelles fonctionnalités du Serializer il utilise. Voici la matrice de compatibilité :

Fonctionnalité SerializerCompatible JsonStreamer ?
Groupes de normalisation (groups)Non
#[SerializedName]Oui, équivalent disponible
Contexte dynamiqueNon
Callbacks customVia ValueTransformer
Objets imbriquésOui
CollectionsOui
DateTimeVia ValueTransformer
Circular reference handlerNon applicable (pas de référence circulaire en DTO)

Si ton endpoint utilise des groupes ou du contexte dynamique, la migration demande du refactoring structurel, pas juste un changement d'appel.

Ce que tu perds, soyons honnêtes

Les groupes de sérialisation

C'est la perte la plus douloureuse. Si tu as un User avec ["groups" => ["public"]] pour l'API publique et ["groups" => ["admin"]] pour le back-office, tu dois créer deux DTOs distincts : PublicUserDto et AdminUserDto.

// Avant : un seul objet, deux vues
$json = $serializer->serialize($user, 'json', ['groups' => ['public']]);

// Après : un DTO par vue
$dto = PublicUserDto::fromEntity($user);
$json = $jsonStreamer->serialize($dto);

C'est plus verbeux, mais architecturalement plus propre. Chaque représentation est explicite dans le code, pas cachée dans une annotation. Si tu travailles en architecture hexagonale, tu as probablement déjà ces DTOs.

Le contexte dynamique

Fini le $context['datetime_format'] = 'Y-m-d' à la volée. Tout doit être configuré statiquement via des ValueTransformer. Pour des formats de dates qui varient selon l'appelant (API mobile vs API web, par exemple), c'est un problème. La solution : un DTO par format, ou un ValueTransformer configurable via un service.

La compatibilité avec JMS Serializer

Si tu utilises JMS Serializer en plus du Serializer natif Symfony, JsonStreamer ne s'y substitue pas. Les deux ciblent des usages différents. JMS reste pertinent pour ses fonctionnalités avancées (versionning, exclusion strategies, virtual properties).

La stratégie de migration

Pas de big bang. Les deux composants coexistent sans problème dans la même application Symfony. La migration se fait endpoint par endpoint, en commençant par les cas les plus simples et les plus impactants en performance.

La règle de décision en 3 questions

Avant de migrer un endpoint, pose-toi ces trois questions dans l'ordre :

  1. Mon endpoint retourne plus de 1 000 objets ? Si non, le gain de performance est négligeable, garde le Serializer.
  2. J'utilise des groupes de sérialisation sur cet endpoint ? Si oui, tu devras refactorer tes DTOs avant de migrer, évalue le coût.
  3. Mes objets sont des DTOs purs (pas de constructeur avec arguments, propriétés publiques) ? Si oui, JsonStreamer est viable immédiatement.
flowchart TD
    A[Endpoint à évaluer] --> B{PHP >= 8.4 ?}
    B -- Non --> C[Migration impossible]
    B -- Oui --> D{Plus de 1000 objets ?}
    D -- Non --> E[Serializer suffit]
    D -- Oui --> F{Groupes de sérialisation ?}
    F -- Oui --> G{Refactoring DTOs acceptable ?}
    G -- Non --> E
    G -- Oui --> H[Créer DTOs dédiés puis migrer]
    F -- Non --> I{DTOs purs ?}
    I -- Non --> J[Adapter les classes d'abord]
    I -- Oui --> K[Migrer vers JsonStreamer]

Coexistence dans le même projet

Les deux composants s'injectent indépendamment. Tu peux utiliser le Serializer sur 90% de tes endpoints et JsonStreamer sur les 10% qui en ont besoin :

# config/services.yaml
services:
    _defaults:
        autowire: true
        autoconfigure: true

Aucune configuration spéciale n'est nécessaire. Symfony 7.3 enregistre automatiquement le service JsonStreamWriter quand le composant est installé. Les deux coexistent nativement.

La migration pas à pas sur un endpoint concret

Avant, avec le Serializer

Un contrôleur classique qui expose une collection de produits :

#[Route('/api/products', methods: ['GET'])]
public function list(
    ProductRepository $repository,
    SerializerInterface $serializer,
): JsonResponse {
    $products = $repository->findAll();

    return new JsonResponse(
        $serializer->serialize($products, 'json', [
            'groups' => ['api:product:list'],
        ]),
        json: true,
    );
}

Ce contrôleur charge tous les produits en mémoire, les sérialise en un seul bloc JSON, puis renvoie la réponse. Sur 50 000 produits, c'est 24 MB de RAM et plus de 340 ms d'attente.

Après, avec JsonStreamer

D'abord, le DTO compatible :

namespace App\Dto;

use Symfony\Component\JsonStreamer\Attribute\JsonStreamable;

#[JsonStreamable]
class ProductDto
{
    public int $id;
    public string $name;
    public float $price;
    public string $category;
    public string $createdAt;

    public static function fromEntity(Product $product): self
    {
        $dto = new self();
        $dto->id = $product->getId();
        $dto->name = $product->getName();
        $dto->price = $product->getPrice();
        $dto->category = $product->getCategory()->getName();
        $dto->createdAt = $product->getCreatedAt()->format('Y-m-d');

        return $dto;
    }
}

Ensuite, le contrôleur migré :

use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\JsonStreamer\JsonStreamWriter;
use Symfony\Component\TypeInfo\Type;

#[Route('/api/products', methods: ['GET'])]
public function list(
    ProductRepository $repository,
    JsonStreamWriter $streamWriter,
): StreamedResponse {
    $products = $repository->findAll();
    $dtos = array_map(ProductDto::fromEntity(...), $products);

    $json = $streamWriter->write($dtos, Type::list(Type::object(ProductDto::class)));

    return new StreamedResponse($json);
}

La différence fondamentale : le JSON est envoyé au client par morceaux. Le navigateur (ou le client API) commence à recevoir les données pendant que le serveur continue à les produire. Le pic mémoire reste stable quelle que soit la taille de la collection.

Sur 50 000 produits, on passe de ~340 ms à ~54 ms et de ~24 MB à ~8 MB.

Pour aller plus loin

JsonStreamer n'est pas le futur du Serializer, c'est un outil spécialisé pour un problème précis. Si tu fais du volume, il est imbattable. Si tu as besoin de flexibilité de vue avec des groupes et du contexte dynamique, garde le Serializer. Les deux coexistent, utilise-les ensemble. La migration intelligente, c'est endpoint par endpoint, en commençant par ceux où le gain est mesurable.