Image de couverture : Symfony 7.4 : Streamer du JSON sans exploser la RAM avec symfony/json-streamer
tech

Symfony 7.4 : Streamer du JSON sans exploser la RAM avec symfony/json-streamer

08 April 2026
2 min de lecture
3 vues
Sébastien Muler

Le problème : json_decode() ne passe pas à l'échelle

Si vous avez déjà tenté d'importer un catalogue produit de 2 Go via une API Symfony, vous connaissez la sentence redoutée : Allowed memory size exhausted. L'OOM killer frappe, le worker tombe, et le ticket urgence atterrit dans votre backlog.

La cause est structurelle. json_decode() — et par extension le composant symfony/serializer qui s'appuie dessus — adopte une approche DOM : il charge l'intégralité de la chaîne JSON en mémoire, analyse sa syntaxe, puis construit un arbre d'objets ou de tableaux PHP. Un fichier de 100 Mo en entrée peut consommer 300 à 400 Mo de RAM une fois transformé en structure PHP. Sur des webhooks massifs ou des exports partenaires, c'est la garantie d'une instabilité en production.

Jusqu'à Symfony 7.4, les équipes compensaient avec des librairies tierces (halaxa/json-machine, salsify/json-streaming-parser) ou des scripts de découpe artisanaux. Depuis la stabilisation de symfony/json-streamer, il existe une solution native, intégrée et optimisée.


Comment fonctionne le streaming JSON ?

La différence fondamentale repose sur l'approche SAX (event-driven) vs DOM (tree-based).

Avec le streaming, le parser lit le flux d'octets séquentiellement, token par token, sans jamais matérialiser l'arbre complet en mémoire. Il émet des événements (start_object, key, value, end_array…) que votre code consomme au fil de la lecture. À chaque instant, seul l'élément courant occupe de la mémoire.

Résultat concret : un fichier JSON de 2 Go traité avec moins de 20 Mo de RAM au lieu de plusieurs centaines.


Migration pratique : de json_decode() à symfony/json-streamer

1. Installation

composer require symfony/json-streamer

Le composant s'intègre nativement à Symfony 7.2+ et atteint sa maturité avec la version 7.4.

2. Définir un DTO mappé

Plutôt que de manipuler des tableaux anonymes, on déclare une classe PHP typée. Le streamer peut mapper directement le flux JSON vers vos objets.

<?php

namespace App\Dto;

use Symfony\Component\JsonStreamer\Attribute\JsonStreamable;

#[JsonStreamable]
class Product
{
    public function __construct(
        public readonly int $id,
        public readonly string $name,
        public readonly float $price,
        public readonly bool $inStock,
    ) {}
}

L'attribut #[JsonStreamable] indique au composant que cette classe est une cible de désérialisation streamée.

3. Streamer un fichier JSON

<?php

namespace App\Service;

use App\Dto\Product;
use Symfony\Component\JsonStreamer\JsonStreamReader;

class ProductImporter
{
    public function __construct(
        private readonly JsonStreamReader $reader,
    ) {}

    public function import(string $filePath): void
    {
        $stream = fopen($filePath, 'r');

        /** @var iterable<Product> $products */
        $products = $this->reader->read($stream, Product::class . '[]');

        foreach ($products as $product) {
            // Traitement unitaire — mémoire constante
            $this->processProduct($product);
        }

        fclose($stream);
    }

    private function processProduct(Product $product): void
    {
        // Persistance, transformation, dispatch d'événement...
    }
}

Notez le Product::class . '[]' : c'est la syntaxe pour indiquer qu'on attend un tableau d'objets Product à la racine du JSON.

4. Cas webhook en streaming

Pour un webhook HTTP, le principe est identique mais on lit directement depuis php://input :

$stream = fopen('php://input', 'r');
$items = $this->reader->read($stream, Product::class . '[]');

foreach ($items as $item) {
    $this->bus->dispatch(new ImportProductCommand($item));
}

Le Message Bus de Symfony s'intègre naturellement : chaque objet est dispatchable dès sa désérialisation, sans attendre la fin du flux.


Les pièges courants à éviter

Ne jamais convertir l'itérable en tableau. C'est le piège le plus fréquent :

// ❌ Annule tout le bénéfice du streaming
$products = iterator_to_array(
    $this->reader->read($stream, Product::class . '[]')
);

// ✅ Toujours consommer avec foreach
foreach ($this->reader->read($stream, Product::class . '[]') as $product) {
    // ...
}

Gérer les erreurs par lot. Sans gestion d'exception dans la boucle, une ligne malformée peut interrompre tout l'import. Enveloppez le traitement :

foreach ($products as $index => $product) {
    try {
        $this->processProduct($product);
    } catch (\Throwable $e) {
        $this->logger->error('Import failed', [
            'index' => $index,
            'error' => $e->getMessage(),
        ]);
    }
}

Flusher l'EntityManager périodiquement. Si vous persistez en base, Doctrine accumule les entités en mémoire. Forcez un flush+clear tous les N éléments :

if ($index % 500 === 0) {
    $this->em->flush();
    $this->em->clear();
}

Conclusion

symfony/json-streamer apporte enfin une réponse native et élégante à un problème récurrent en production : le traitement de flux JSON massifs sans saturer la RAM. La migration depuis json_decode() est accessible — quelques minutes pour installer le composant, un attribut sur votre DTO, et une boucle foreach qui remplace votre appel habituel.

Les gains sont significatifs : jusqu'à 10x moins de mémoire consommée selon les benchmarks rapportés par l'article original de Matt Mochalkin sur dev.to. Sur des architectures avec des workers Docker limités en RAM, ou des imports planifiés de catalogues partenaires, c'est la différence entre un système stable et un OOM killer récurrent.

Si votre stack tourne déjà sur Symfony 7.2+, il n'y a aucune raison d'attendre pour adopter ce composant.

Partager cet article