Arrêtez de charger toute votre base : traitez vos données intelligemment
Vous avez 100'000 utilisateurs en base, vous lancez votre script ... trente secondes plus tard, le processus est tué : Mémoire dépassée.
Vous relancez avec un peu plus de RAM, même résultat, vous commencez à chercher du côté de l'infrastructure, le problème n'est pas là.
Il est dans cette ligne :
1$subscribers = DemoSubscriber::all();
C'est exactement le problème que règlent les méthodes de traitement par lots de Laravel.
Le problème : tout charger d'un coup
En local, vous avez toujours travaillé avec 15 ou 20 lignes de données. ::all() est élégant, rapide à écrire, et il fonctionne parfaitement dans ce contexte. C'est l'une des raisons pour lesquelles on tombe amoureux de Laravel.
En production, c'est une autre histoire, voici ce qui se passe quand vous appelez ::all() sur une table volumineuse.
Laravel envoie une requête SQL qui récupère toutes les lignes en une seule fois, PHP alloue la mémoire nécessaire pour hydrater tous les modèles simultanément.
La mémoire explose avant même que vous ayez traité la première ligne, le processus est tué par le système, ou la requête timeout.
100'000 lignes, c'est peu comparé à ce qu'on trouve en production. Mais c'est déjà suffisant pour faire tomber un serveur mal préparé.
La solution n'est pas d'acheter plus de RAM. Laravel met à votre disposition plusieurs méthodes pensées précisément pour ce problème. En voici six avec leurs cas d'usage.
Traiter par blocs
chunk() divise votre jeu de données en blocs de 200 enregistrements et les traite successivement.
1DemoSubscriber::orderBy('id')->chunk(200, function ($subscribers) {2 foreach ($subscribers as $subscriber) {3 // traitement par lot de 2004 }5});
Sur 100'000 lignes, vous obtenez 501 requêtes SQL au lieu d'une seule monstrueuse, la mémoire reste stable et le serveur respire.
C'est déjà infiniment mieux qu'un ::all(), mais il y a un piège sérieux à connaître.
chunk() fonctionne en interne avec un LIMIT + OFFSET, pour récupérer le second bloc, Laravel demande à MySQL de sauter les 200 premières lignes et de renvoyer les 200 suivantes, et c'est là que ça coince : si vous modifiez des données pendant l'itération, l'offset se décale.
Un enregistrement ajouté pendant le traitement peut se retrouver dans deux blocs différents. Un enregistrement supprimé peut en faire sauter un autre. Si votre traitement multiplie une valeur par 2, certains enregistrements se retrouveront multipliés par 4 parce qu'ils auront été traités deux fois.
La version robuste pour les mises à jour
chunkById() produit le même résultat visible que chunk() : même découpage, même nombre de requêtes SQL mais son fonctionnement interne est fondamentalement différent.
1DemoSubscriber::orderBy('id')->chunkById(200, function ($subscribers) {2 foreach ($subscribers as $subscriber) {3 $subscriber->update(['processed' => true]);4 }5});
Au lieu d'un LIMIT/OFFSET, il se base sur le curseur de l'ID. Pour passer au bloc suivant, la requête ne dit pas "saute les 200 premiers" elle dit "donne-moi les 200 enregistrements dont l'ID est supérieur à 200".
La grande différence, la progression se fait toujours vers l'avant ancrée sur une valeur stable.
Conséquence directe : si vous supprimez une ligne pendant le traitement, ça n'affecte pas les blocs suivants. Si vous en ajoutez une, elle aura un ID plus grand que ceux déjà traités et sera prise en compte à la fin. Plus de décalage, plus de doublons.
Un modèle à la fois
lazyById() utilise le même mécanisme de curseur ID que chunkById(). Le nombre de requêtes SQL est identique. Ce qui change, c'est la façon dont vous consommez les données côté PHP.
1DemoSubscriber::orderBy('id')->lazyById(200)->each(function ($subscriber) {2 // un seul modèle à la fois3});
Avec chunkById(), vous recevez une collection de 200 modèles à chaque bloc, que vous parcourez en boucle interne. Avec lazyById(), vous obtenez un flux dans lequel chaque modèle est hydraté et livré un par un, à mesure que vous en avez besoin.
Imaginez deux bibliothécaires. Le premier vous apporte une caisse de 200 livres, vous les lisez, il la remporte, il en apporte une autre. Le second vous tend les livres un par un, à la demande. Le nombre total de livres est le même, mais la façon de les manipuler est différente.
En pratique, lazyById() brille quand vous faites de l'écriture unitaire : export CSV ligne par ligne, génération d'un fichier Excel, envoi d'emails individuels. Chaque modèle est traité puis libéré, sans jamais accumuler 200 instances en mémoire simultanément.
Une seule requête, mémoire minimale
cursor() change radicalement de philosophie. Là où toutes les méthodes précédentes envoient des requêtes SQL successives, cursor() n'en envoie qu'une seule.
1foreach (DemoSubscriber::orderBy('id')->cursor() as $subscriber) {2 // lecture uniquement3}
Laravel ouvre une connexion à la base de données et maintient un flux ouvert : les lignes arrivent au fil de l'eau et chaque modèle est hydraté à la volée puis consommé immédiatement.
Sur 100'000 lignes : 1 requête SQL au lieu de 501. L'empreinte mémoire PHP est la plus faible de toutes les méthodes, c'est la solution la plus efficace pour parcourir de grands volumes en lecture.
Mais parce que tout se passe dans un seul flux ouvert, vous ne savez pas précisément où vous en êtes.
Si une erreur survient au milieu du traitement, vous ne pouvez pas reprendre là où vous vous étiez arrêté contrairement à chunkById(), où vous connaissez toujours le dernier ID traité.
La pagination qui tient à l'échelle
Le dernier problème n'est pas un traitement batch, mais une pagination publique ... et c'est souvent le plus sous-estimé en production.
La pagination classique avec paginate() fonctionne avec un LIMIT/OFFSET. Pour afficher la page 500 d'un infinite scroll, MySQL doit parcourir les 99'900 lignes précédentes avant de savoir quoi renvoyer.
Avec un million d'utilisateurs, naviguer loin dans la pagination devient une opération qui prend plusieurs secondes et qui empire à mesure que la base grossit.
1$subscribers = DemoSubscriber::orderBy('id')->paginate(20);2 3$subscribers = DemoSubscriber::orderBy('id')->cursorPaginate(20);
cursorPaginate() utilise le même principe de curseur ID. Chaque page encode la position courante dans un token passé en paramètre. La requête suivante démarre directement depuis cet ID, sans jamais relire ce qui précède.
1WHERE id > {cursor} ORDER BY id LIMIT 20
Performance constante, quelle que soit la profondeur, même avec des milliards de lignes, la 50'000ème page répond aussi vite que la première.
La contrepartie : vous ne pouvez plus sauter directement à une page arbitraire. La navigation est toujours séquentielle (précédent / suivant).
Pour un infinite scroll ou un flux paginé, c'est exactement le comportement attendu. Pour une interface avec un champ "aller à la page X", paginate() reste nécessaire.
Conclusion
Ces méthodes existent depuis un moment dans Laravel. Elles ne sont pas obscures elles sont dans la documentation. Mais elles restent sous-utilisées parce qu'on ne ressent pas leur nécessité en local, et qu'on ne les cherche souvent qu'après un incident en production.
La règle de base est simple. Si vous lisez sans modifier, cursor() est presque toujours optimal. Si vous modifiez des données, utilisez chunkById(). Si vous exportez ligne par ligne, lazyById() est le bon outil. Et si vous paginez un flux public, remplacez paginate() par cursorPaginate() dès que votre table commence à grossir.
Sans ces méthodes : un all() sur 100 000 lignes fait tomber votre serveur.
Avec elles : le même volume est traité proprement, sans pic mémoire, sans timeout, et sans mauvaise surprise en production.
Tu veux commenter ? Crée un compte ou connecte-toi.
A lire
Autres articles de la même catégorie
Déployer vos applications serverless avec Bref.sh
Depuis quelques années, les architectures serverless ont le vent en poupe. Les avantages sont nombreux notamment la scalabilité, la gestion des ressources, la facturation à l'usage, et bien d'autres. Mais comment en profiter pour une application PHP ?
Antoine Benevaut
Pourquoi Vite ne build pas ?
Un retour d'experience douloureux mais formateur sur le fonctionnement de Vite !
Mathieu De Gracia
Découvrez les secrets du verrou pessimiste avec Laravel 🔒
Découvrez comment un simple bug peut ruiner vos transactions !
Ludovic Guénet