JSON Streaming natif dans Symfony 7.4 : dites adieu aux erreurs Out of Memory
Votre application Symfony reçoit un catalogue produit de 2 Go en JSON depuis l'API d'un partenaire. Quelques secondes plus tard : Fatal error: Allowed memory size exhausted. Ce scénario, cauchemar de tout développeur PHP, est pourtant évitable grâce au composant natif symfony/json-streamer, stabilisé avec Symfony 7.4. Dans ce guide, nous allons comprendre pourquoi json_decode() capitule face aux gros fichiers, et comment migrer vers une approche streaming qui réduit la consommation mémoire d'un facteur 10.
Source originale : 10x Less RAM: The Senior Guide to Native JSON Streaming in Symfony par Matt Mochalkin sur dev.to.
Pourquoi json_decode() et le Serializer de Symfony échouent sur les gros volumes
La fonction native json_decode() — et par extension le composant symfony/serializer qui s'appuie dessus — utilise une approche DOM (Document Object Model) : elle charge la totalité du contenu JSON en mémoire, valide sa syntaxe, puis construit une structure PHP complète (tableau associatif ou arbre d'objets) avant de vous en remettre le résultat.
Concrètement, pour un fichier JSON de 100 Mo :
- Le chargement de la chaîne en mémoire consomme ~100 Mo.
- La construction du tableau PHP associatif multiplie ce volume par 3 à 5.
- On se retrouve facilement à 300-500 Mo de RAM pour un seul fichier.
Face à un fichier de 2 Go, la machine abandonne bien avant la fin du traitement. Aucun réglage de memory_limit ne remplace une architecture adaptée.
L'approche streaming : traiter les données sans tout charger
Le streaming JSON repose sur un paradigme SAX (Simple API for XML, transposé au JSON) : le parseur lit le flux d'octets de manière séquentielle et émet des événements (début d'objet, clé, valeur, fin de tableau…) au fur et à mesure. Votre code traite chaque élément immédiatement, sans jamais stocker la totalité du document.
Installation du composant
composer require symfony/json-streamer
Le composant s'intègre nativement au conteneur de services Symfony. Aucune configuration supplémentaire n'est requise pour un projet Symfony 7.4+.
Définir un DTO optimisé
Le json-streamer mappe le flux directement vers vos objets PHP. Définissez un DTO simple et typé :
<?php
namespace App\Dto;
use Symfony\Component\JsonStreamer\Attribute\JsonStreamable;
#[JsonStreamable]
final class Product
{
public function __construct(
public readonly int $id,
public readonly string $name,
public readonly float $price,
public readonly string $sku,
) {}
}
L'attribut #[JsonStreamable] indique au composant que cette classe est éligible au mapping en streaming. Symfony génère automatiquement les classes de normalisation optimisées.
Streamer un fichier JSON volumineux
<?php
namespace App\Service;
use App\Dto\Product;
use Symfony\Component\JsonStreamer\JsonStreamReaderInterface;
final class ProductImportService
{
public function __construct(
private readonly JsonStreamReaderInterface $jsonStreamReader,
) {}
public function import(string $filePath): void
{
$stream = fopen($filePath, 'r');
/** @var iterable<Product> $products */
$products = $this->jsonStreamReader->read($stream, Product::class . '[]');
foreach ($products as $product) {
// Traitement unitaire : insertion BDD, validation, dispatch d'événement...
$this->process($product);
}
fclose($stream);
}
private function process(Product $product): void
{
// Votre logique métier ici
}
}
⚠️ Point clé :
$this->jsonStreamReader->read()retourne uniterable, pas un tableau. PHP ne charge qu'un objet à la fois en mémoire. Ne jamais appeleriterator_to_array()sur ce résultat, sous peine de recréer le problème DOM initial.
Les pièges à éviter absolument
Même avec le bon outil, certains réflexes de développement peuvent annuler tous les bénéfices du streaming.
❌ Piège 1 : accumuler les résultats dans un tableau
// À NE PAS FAIRE
$allProducts = iterator_to_array($products); // Charge tout en RAM !
foreach ($allProducts as $product) { ... }
// CORRECT
foreach ($products as $product) { ... }
❌ Piège 2 : oublier de vider l'EntityManager Doctrine
Si vous persistez les entités via Doctrine dans la boucle, l'UnitOfWork accumule les références en mémoire. Purgez régulièrement :
foreach ($products as $index => $product) {
$this->entityManager->persist($this->toEntity($product));
if ($index % 500 === 0) {
$this->entityManager->flush();
$this->entityManager->clear(); // Libère les références internes
}
}
$this->entityManager->flush();
$this->entityManager->clear();
❌ Piège 3 : utiliser des ressources non-stream
Le composant attend une ressource PHP (resource de type stream). Passer directement une chaîne de caractères ou un objet Response HTTP non streamé contourne le mécanisme et provoque un chargement complet en mémoire. Utilisez fopen(), php://input pour les webhooks, ou les streams de Symfony HttpClient.
// Pour un webhook entrant
$stream = fopen('php://input', 'r');
$products = $this->jsonStreamReader->read($stream, Product::class . '[]');
Résultats mesurables : ce que vous gagnez concrètement
Voici une comparaison représentative pour l'import d'un fichier JSON contenant 500 000 produits (~800 Mo) :
| Approche | Pic mémoire | Durée (approx.) |
|---|---|---|
json_decode() + Serializer |
~2,4 Go (OOM) | — |
| Bibliothèque tierce (JsonMachine) | ~180 Mo | ~95 s |
symfony/json-streamer natif |
~22 Mo | ~80 s |
La réduction mémoire est de l'ordre de 10x par rapport aux solutions tierces équivalentes, et rend le traitement de gros volumes possible dans des environnements contraints (conteneurs Docker avec limites mémoire, workers en environnement serverless).
Conclusion
Le composant symfony/json-streamer n'est pas un luxe réservé aux cas extrêmes : dès que votre application ingère des flux JSON dépassant quelques dizaines de mégaoctets, c'est l'architecture à adopter. La migration depuis json_decode() est progressive — commencez par vos imports les plus lourds — et les gains sont immédiats et mesurables.
Les points à retenir :
- Ne jamais appeler
iterator_to_array()sur un itérable streamé. - Purger l'EntityManager Doctrine par lots lors des imports massifs.
- Toujours passer une ressource stream, jamais une chaîne.
- Annoter vos DTOs avec
#[JsonStreamable]pour activer le mapping natif.
Si vous travaillez sur des intégrations API, des imports de catalogues ou des traitements de webhooks à fort volume, l'investissement dans cette migration se rentabilise dès le premier incident mémoire évité en production.