Si tu as déjà lancé un import XML « un peu gros » en PHP, tu connais l’histoire : ça marche sur ton laptop avec 2000 lignes… puis en prod tu te prends un Allowed memory size exhausted dès que le fournisseur t’envoie 800 Mo et 2,5 millions de nœuds. Et souvent, le vrai problème n’est pas ton code métier. C’est juste que tu as choisi un parseur qui charge tout en mémoire.
Ici on va faire un truc simple, robuste et boring : streaming avec XMLReader, extraction de « records » répétés (genre <product>, <offer>, <item>), traitement au fil de l’eau via un générateur, et écriture en base par chunks. Le but est clair : mémoire stable, import qui avance, logs exploitables, et la possibilité de reprendre après un crash.
Pourquoi DOMDocument et SimpleXML finissent par exploser la RAM
DOMDocument et SimpleXML sont super… tant que le XML reste « petit ». Leur modèle est simple : on lit le fichier, on construit un arbre complet d’objets en mémoire, puis on navigue dedans (XPath, itérations, etc.). Sur un gros feed, tu te retrouves à garder l’intégralité du document en RAM, plus les structures internes de libxml, plus tes propres tableaux/objets PHP. Et PHP n’est pas connu pour sa légèreté sur les structures en mémoire.
Le piège classique, c’est de se dire « je ne garde pas tout, je boucle et j’insert au fur et à mesure ». Sauf que si ton arbre DOM/SimpleXML est déjà là, tu as perdu. Autre classique : un XPath “pratique” qui déclenche des traversées coûteuses, ou des conversions implicites en string qui copient des bouts de contenu. Sur un XML massif, ça finit rarement bien.
La bonne mentalité, c’est : je ne veux jamais avoir le document complet en mémoire. Je veux un curseur qui avance, et je veux pouvoir traiter un record puis l’oublier.
Sponsorisé par Le Scribouillard
Besoin de contenu optimisé SEO ?
Utilisez la meilleure plateforme française de création de contenu assistée par IA ! Et générez des articles pour moins de 1€ !
Le pattern qui marche : XMLReader + “record répété” (product, offer, item…)
XMLReader, c’est un lecteur « forward-only ». Tu avances dans le flux, tu inspectes le nœud courant, et tu continues. C’est moins confortable que SimpleXML, mais en échange tu gagnes ce que tu veux vraiment sur un gros import : une mémoire qui ne grimpe pas à chaque itération.
Dans 90% des feeds, tu as un schéma répétitif : un wrapper, puis une liste de records. Exemple caricatural : <catalog><products><product>...</product><product>...</product>...</products></catalog>. Ton job, c’est d’avancer jusqu’à <product>, d’en extraire ce dont tu as besoin, et de passer au suivant. Tout le reste est du bruit.
Une approche que j’aime bien en PHP : quand tu es sur le nœud du record, tu récupères readOuterXML() pour ce sous-arbre (donc un fragment XML), tu le parses, tu en sors un tableau propre, et tu continues. Oui, tu re-parses un fragment pour chaque record. En pratique, c’est souvent acceptable et surtout très stable. Et si tu veux pousser plus loin, tu peux aussi lire champ par champ directement avec XMLReader, mais tu vas écrire plus de code.
Sortir des records en streaming avec un générateur (yield), pour garder la mémoire plate
Le générateur est un détail qui change beaucoup de choses : tu peux consommer ton import comme un flux. Tu peux arrêter proprement, tu peux mettre du backpressure côté DB, tu peux instrumenter facilement, et tu ne te retrouves pas à accumuler « juste pour simplifier ».
Voilà un exemple réaliste : on lit un fichier (ou un .gz), on avance jusqu’à <product>, on transforme en tableau. Le parsing interne du fragment est fait avec SimpleXML, mais sur un record, pas sur 800 Mo.
<?php
/**
* Lit un gros XML en streaming et yield des products sous forme de tableaux.
* Hypothèse : des noeuds <product> répétés.
*/
function streamProducts(string $path): Generator
{
$reader = new XMLReader();
// Astuce utile si tu reçois des .xml.gz :
// $path = 'compress.zlib://' . $path;
if (!$reader->open($path, null, LIBXML_NONET | LIBXML_COMPACT)) {
throw new RuntimeException("Impossible d'ouvrir le fichier XML: {$path}");
}
// On cherche uniquement les <product>
while ($reader->read()) {
if ($reader->nodeType === XMLReader::ELEMENT && $reader->name === 'product') {
$fragment = $reader->readOuterXML();
// Parse uniquement ce sous-arbre
$sx = simplexml_load_string($fragment, 'SimpleXMLElement', LIBXML_NONET);
if ($sx === false) {
// Sur du XML sale, je préfère logger et continuer (selon ton besoin)
continue;
}
$product = [
'external_id' => (string)($sx->id ?? ''),
'name' => trim((string)($sx->name ?? '')),
'price' => (float)($sx->price ?? 0),
'updated_at' => (string)($sx->updated_at ?? ''),
];
yield $product;
}
}
$reader->close();
}Deux remarques terrain. D’abord, si ton record a des namespaces, des attributs, des champs optionnels, c’est là que ton mapping doit être solide. Ne pars pas du principe que « le XML est propre », surtout sur des flux marketplace. Ensuite, ne sous-estime pas le coût de readOuterXML(). Ça reste très souvent le meilleur compromis effort/robustesse, mais si tu as un record énorme (un <product> avec 500 images et 200 variations), tu peux basculer sur une lecture plus fine.
Écrire en base sans se tirer une balle : chunks, transactions, et backpressure
Un import streaming, ça peut aller tellement vite que c’est la base qui devient le goulot. Et là, si tu fais un INSERT par record, tu vas transformer ton import en broyeur à I/O. Le bon move, c’est d’accumuler un petit buffer (500, 1000, 5000 selon ton schéma et tes index), puis d’écrire en une fois, idéalement dans une transaction courte.
Ce n’est pas juste une question de perf. C’est aussi une question de stabilité : tu limites le temps passé en lock, tu réduis le nombre de round-trips, et tu peux mesurer précisément « un chunk = une unité de progression ». Si ta DB commence à souffler, tu peux même ralentir volontairement, plutôt que de continuer à empiler et finir par tout casser.
<?php
$pdo = new PDO($dsn, $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
$chunkSize = 1000;
$buffer = [];
$seen = 0;
foreach (streamProducts($path) as $product) {
$buffer[] = $product;
$seen++;
if (count($buffer) >= $chunkSize) {
$pdo->beginTransaction();
// Exemple volontairement simple : adapte à ton moteur et à ton schéma.
// Pour du vrai bulk insert, génère une requête multi-values ou utilise une méthode dédiée.
$stmt = $pdo->prepare('INSERT INTO products (external_id, name, price, updated_at)
VALUES (:external_id, :name, :price, :updated_at)
ON DUPLICATE KEY UPDATE name=VALUES(name), price=VALUES(price), updated_at=VALUES(updated_at)');
foreach ($buffer as $row) {
$stmt->execute($row);
}
$pdo->commit();
$buffer = [];
// Logs de progression utiles en prod
error_log("Imported {$seen} products, mem=" . round(memory_get_usage(true)/1024/1024, 1) . "MB");
// Backpressure très basique si tu as un système fragile (à ajuster)
// usleep(50_000);
}
}
// flush final
if ($buffer) {
$pdo->beginTransaction();
$stmt = $pdo->prepare('INSERT INTO products (external_id, name, price, updated_at)
VALUES (:external_id, :name, :price, :updated_at)
ON DUPLICATE KEY UPDATE name=VALUES(name), price=VALUES(price), updated_at=VALUES(updated_at)');
foreach ($buffer as $row) {
$stmt->execute($row);
}
$pdo->commit();
}Oui, je sais, c’est encore un execute() par ligne. En vrai, le bulk insert dépend de ton stack (MySQL, PostgreSQL, ORM, etc.) et de tes contraintes (upsert, contraintes d’unicité, triggers). L’idée à retenir, c’est le rythme : tu ne veux pas faire une écriture par record si tu peux l’éviter, et tu veux que ton import puisse respirer sans que la RAM serve de tampon.
Les garde-fous de prod qui évitent les imports “fantômes” et les nuits pourries
Sur un import XML, ce qui te fait mal n’est pas juste l’OOM. C’est l’import qui tourne 45 minutes, puis crashe sans te dire où il en était, et tu dois relancer en espérant que « ça passe ». Donc je mets des garde-fous dès le départ.
Le XML malformé, d’abord. Ça arrive tout le temps. Un caractère interdit, une balise pas fermée, une entité bizarre. XMLReader est assez tolérant, mais pas magique. Si ton flux est fréquemment cassé, tu dois choisir : fail fast (tu refuses le fichier) ou best effort (tu sautes les records cassés, tu logges, tu continues). Le pire choix, c’est de ne rien décider et de découvrir en prod que tu fais n’importe quoi.
L’encodage, ensuite. Tu vas recevoir du UTF-8, du ISO-8859-1 déguisé, des CDATA chelous. Quand tu vois des noms produits avec des losanges noirs, ne te raconte pas d’histoires : traite l’encodage à l’entrée, avant de remplir ta base, et logge les anomalies. Rien n’est plus pénible qu’un import qui « marche » mais pollue toutes tes chaînes.
Les timeouts et la durée totale comptent aussi. En CLI, tu es plus libre, mais tu peux quand même te faire tuer par un supervisor, un job runner, ou une politique d’infra. Mesure le temps par chunk, et si ça dérive, tu le verras vite. Et si tu es en HTTP (mauvaise idée pour du gros), tu as déjà perdu : mets ça dans un worker/queue et dors tranquille.
La reprise après crash, enfin. Si tu peux, garde un marqueur de progression. Le plus simple, c’est souvent de raisonner par identifiant externe : tu stockes le dernier external_id importé (ou un hash/chunk id) et tu sais redémarrer sans doubler. Attention : ça suppose que ton flux est stable et ordonné, ce qui n’est pas garanti. Quand ce n’est pas garanti, l’approche réaliste est l’idempotence côté DB (upsert propre, contraintes d’unicité, tables de staging), plutôt qu’un « curseur » fragile.
Les erreurs que je vois tout le temps (et qui ruinent l’intérêt du streaming)
La première, c’est de faire du streaming… puis de reconstruire un tableau énorme « juste pour debug » ou « juste pour faire un tri ». Tu reviens au même problème, avec une couche de déni en plus. Si tu dois trier, agréger, dédupliquer à grande échelle, il faut le faire autrement : staging en base, traitement batch, ou une passe dédiée, mais pas en entassant en RAM.
La deuxième, c’est d’avoir un mapping trop naïf. En prod, tu as des champs manquants, des formats de prix variables, des dates inconsistantes, des valeurs absurdes. Si tu ne normalises pas au fil de l’eau, tu vas te retrouver avec une base incohérente et un import impossible à fiabiliser. Et là, tu vas accuser « le parseur », alors que ton problème est la validation.
La troisième, c’est de ne rien logger de concret. Sur un gros import, tu veux au minimum un compteur de records, une idée du débit, et la mémoire. Pas pour faire joli, juste pour pouvoir répondre à la question : « ça avance ou c’est coincé ? ». Et si tu peux logguer un identifiant de record quand ça casse, tu viens de t’éviter une séance d’archéologie.
Mon arbitrage : le XML “gros” se traite comme un flux, pas comme un document
Le shift mental, il est là. Tant que tu vois le XML comme « un fichier à charger », tu vas naturellement tomber dans DOM/SimpleXML et bricoler des rustines. Quand tu le vois comme « un flux de records », tu finis avec un pipeline qui tient : lecture streaming, transformation, écriture chunkée, et des garde-fous clairs.
Et oui, ça demande un peu plus de code. Mais c’est du code que tu écris une fois, que tu réutilises pour tous tes imports, et qui évite le scénario le plus bête du monde : exploser la RAM sur un job nightly et découvrir le matin que tu n’as rien importé.
Conclusion : vise l’import qui survit aux mauvais fichiers, pas celui qui “marche chez toi”
Si ton XML fait quelques Mo, SimpleXML est très bien et tu peux vivre heureux. Si tu es dans le monde des catalogues, flux partenaires, exports legacy et marketplaces, le « gros » arrive toujours un jour. Et quand il arrive, tu veux un import qui continue d’avancer, qui logge, qui ne sature pas la mémoire, et qui se relance sans stress.
Le pattern XMLReader + générateur + chunks DB, c’est mon défaut. Ensuite tu raffines selon ton contexte : staging table, bulk insert natif, parallélisation, validation plus stricte, ou traitement des namespaces. Mais la base reste la même : streaming, sinon tu joues à la roulette russe avec ta prod.