Un client important nous a signalé qu’un import de catalogue fournisseur, habituellement traité sans problème sur des lots de quelques centaines de références, provoquait un Fatal error: Allowed memory size exhausted dès que le fichier dépassait environ 150 000 lignes. Le code utilisait pourtant déjà un traitement par lots pour éviter les dépassements de délai d’exécution, sujet traité par ailleurs sur ce blog. Le problème ici n’était pas la durée du traitement, mais sa consommation mémoire croissante au fil des lots.
Ce cas mérite un retour détaillé, car la fuite provenait de trois causes distinctes qui s’additionnaient, chacune anodine prise isolément, mais cumulativement responsables de l’épuisement progressif de la mémoire allouée au processus PHP.
Première cause : le cache de requêtes de WordPress
À chaque appel à get_post() ou à WP_Query, WordPress alimente un cache d’objets en mémoire via wp_cache_set(), valable pour la durée de la requête PHP en cours. Sur un traitement web classique, ce cache reste de taille raisonnable, la requête se terminant après quelques dizaines d’appels. Sur un import exécuté en une seule requête longue, via WP-CLI ou une tâche cron, ce cache continue de grossir sans jamais être vidé entre deux lots, chaque nouvelle référence produit ajoutant ses propres entrées.
// Import initial, sans libération de cache
foreach ( $lignes_csv as $ligne ) {
$produit_id = wc_get_product_id_by_sku( $ligne['sku'] ); // alimente le cache à chaque appel
// ... traitement ...
}
Deuxième cause : des objets accumulés dans un tableau global
Le code de l’extension conservait, pour un rapport final envoyé par e-mail à la fin de l’import, un tableau accumulant l’objet complet de chaque produit traité, plutôt que ses seuls identifiants ou un résumé minimal :

$rapport_produits_traites = array();
foreach ( $lignes_csv as $ligne ) {
$produit = wc_get_product( $produit_id );
// ... mise à jour du produit ...
$rapport_produits_traites[] = $produit; // objet complet conservé, jamais libéré
}
Sur 200 000 lignes, ce tableau accumulait autant d’objets WC_Product complets, chacun avec ses propres propriétés et données associées, sans qu’aucune de ces références ne soit jamais libérée avant la toute fin du script. Le ramasse-miettes de PHP ne peut rien faire tant qu’une variable conserve une référence active vers un objet, même inutilisé par la suite.
Troisième cause : des hooks attachés en boucle
Le troisième facteur, plus insidieux encore, provenait d’un appel à add_action() placé par erreur à l’intérieur de la boucle d’import plutôt qu’avant celle-ci, ajoutant un nouveau callback identique à chaque itération sans jamais le retirer :
foreach ( $lignes_csv as $ligne ) {
add_action( 'woocommerce_product_object_updated_props', 'notifier_mise_a_jour_produit' ); // erreur : dans la boucle
// ... traitement du produit, qui déclenche ce hook une fois de plus à chaque appel supplémentaire ...
}
Cette erreur ne provoque pas de fuite mémoire directement visible ligne par ligne, mais chaque produit traité après la centième itération déclenchait déjà une centaine d’exécutions du même callback, chacune consommant de la mémoire temporaire pour son propre traitement, sans être libérée immédiatement.
Le correctif complet
Trois corrections ciblées ont réglé l’ensemble du problème, chacune s’attaquant à l’une des trois causes identifiées :
add_action( 'woocommerce_product_object_updated_props', 'notifier_mise_a_jour_produit' ); // hors de la boucle, une seule fois
$compteur_lot = 0;
$resume_rapport = array( 'traites' => 0, 'erreurs' => 0 );
foreach ( $lignes_csv as $ligne ) {
$produit = wc_get_product( $produit_id );
// ... mise à jour du produit ...
$resume_rapport['traites']++; // un simple compteur, plus d'objet complet conservé
$compteur_lot++;
if ( 0 === $compteur_lot % 500 ) {
wp_cache_flush(); // libère le cache d'objets accumulé sur ce lot
gc_collect_cycles(); // force le ramasse-miettes sur les références circulaires restantes
}
}
L’appel périodique à wp_cache_flush() vide le cache d’objets WordPress accumulé, tandis que gc_collect_cycles() force PHP à traiter immédiatement les références circulaires que son ramasse-miettes automatique ne collecte normalement qu’à intervalles moins prévisibles. Sur un import long, cet appel explicite périodique fait une différence mesurable, en particulier avec des objets qui se référencent mutuellement.
Surveiller la mémoire pendant le développement
- Ajouter un log périodique de
memory_get_usage( true )toutes les quelques centaines d’itérations, pour repérer une croissance continue plutôt qu’un plateau stable. - Tester systématiquement un traitement par lots sur un volume réaliste, pas seulement sur un échantillon de quelques dizaines de lignes qui masquerait toute fuite progressive.
- Vérifier explicitement, avec un outil comme Xdebug en mode profilage, qu’aucun tableau global n’accumule des objets complets sans raison sur toute la durée du script.
Une habitude qui nous a évité bien des surprises depuis : sur tout import volumineux, ne jamais conserver un objet complet dans un tableau de rapport, seulement les identifiants ou un résumé chiffré, quitte à recharger l’objet ponctuellement si le rapport final en a réellement besoin.
En résumé
Une fuite de mémoire sur un traitement long ne provient presque jamais d’une seule cause spectaculaire, mais d’une accumulation de petites négligences : cache non vidé, objets conservés sans nécessité, hooks attachés en boucle. Aucune de ces trois causes n’aurait, isolément, provoqué le fatal error observé chez ce client, ce qui explique pourquoi le bug n’était apparu qu’à partir d’un volume réel de données, bien après la mise en production initiale de l’extension.