Vous souhaitez nous soutenir ? Devenez sponsor de l'association sur notre page Github

Arrêtez de charger toute votre base : traitez vos données intelligemment

Publié le 17 avril 2026 par Ludovic Guénet
Couverture de l'article 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 200
4 }
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 méthode chunk convient à la lecture sans modification. Dès que vous faites des updates, utilisez chunkById

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.

💡
La méthode chunkById est donc la méthode à privilégier pour tous les traitements en masse avec modification des données.

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 fois
3});

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.

💡
lazyById() est taillé pour les exports et les traitements séquentiels, là où chunkById() reste meilleur pour les mises à jour en masse.

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 uniquement
3}

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é.

💡
cursor est réservé à la lecture pure. Dès que vous avez besoin de modifier des données ou de reprendre un traitement interrompu, préférez chunkById

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.

💡
cursorPaginate remplace paginate dès que vous n'avez pas besoin de navigation arbitraire. C'est le choix évident pour les infinite scrolls et les APIs à fort volume.

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.

Ludovic Guénet avatar

Écrit par

Ludovic Guénet

software engineer • mentor • bassist

Aucun commentaire

Tu veux commenter ? Crée un compte ou connecte-toi.

A lire

Autres articles de la même catégorie