Afficher des milliers d'enregistrements sur un seul écran de liste étouffe à la fois le navigateur et la base de données. C'est précisément là qu'intervient la pagination Laravel : elle découpe les résultats d'une requête en pages et ne récupère que la tranche nécessaire à chaque requête. Laravel le fait avec trois méthodes différentes, et celle que tu choisis influe directement sur les performances et l'expérience utilisateur. Dans cet article, j'explique la différence entre la pagination par offset et par curseur, quand utiliser chacune, et comment les implémenter avec du vrai code fonctionnel.
Trois méthodes de pagination : paginate, simplePaginate, cursorPaginate
Eloquent et le query builder fournissent trois méthodes prêtes à l'emploi. La différence réside dans la quantité d'informations qu'elles montrent à l'utilisateur et dans la charge qu'elles imposent à la base de données :
paginate()— Pagination par offset classique. Elle compte aussi le nombre total d'enregistrements, ce qui permet d'afficher des liens de pages numérotés comme « 1 2 3 … 50 » et le nombre total de pages.simplePaginate()— Toujours basée sur l'offset, mais sans compter le total. Elle ne produit que des boutons « Précédent / Suivant » ; sans la requête de comptage, elle est plus rapide.cursorPaginate()— Pagination par curseur. Au lieu d'un offset, elle se réfère aux valeurs de colonne de la dernière ligne ; sur de très grandes tables, c'est la méthode la plus rapide et la plus cohérente.
Comment fonctionne la pagination par offset
L'approche par offset utilise LIMIT et OFFSET en SQL. Avec des pages de 20, lorsque tu demandes la page 5, Laravel produit LIMIT 20 OFFSET 80. C'est la méthode la plus courante et la plus simple :
// Controller
public function index()
{
$posts = \App\Models\Post::query()
->where('published', true)
->orderByDesc('created_at')
->paginate(20);
return view('posts.index', compact('posts'));
}
Côté Blade, tu affiches les liens en une seule ligne. Tu peux personnaliser la vue Tailwind ou Bootstrap avec php artisan vendor:publish --tag=laravel-pagination :
<div class="posts">
@foreach ($posts as $post)
<article>{{ $post->title }}</article>
@endforeach
</div>
{{ $posts->links() }}
L'avantage : l'utilisateur peut sauter directement à n'importe quelle page et voir combien il y en a au total. L'inconvénient apparaît à grande échelle.
Les deux grands problèmes de l'offset
À mesure que la base de données grandit, l'offset cause deux soucis :
- Requêtes qui ralentissent : quand tu écris
OFFSET 100000, la base doit quand même parcourir les 100 000 lignes qu'elle va ignorer. Les pages profondes ralentissent nettement. - Enregistrements qui glissent : si un nouvel enregistrement est inséré pendant que tu es sur la page 2, toutes les lignes descendent d'un cran. Sur la page suivante, tu revois un enregistrement déjà vu, ou tu en sautes un complètement.
Comme les listes sont généralement triées « du plus récent au plus ancien » et que du contenu est ajouté en permanence, ce problème de glissement se produit plus souvent qu'on ne le pense.
La pagination par curseur : le bon choix pour les grandes tables
La pagination par curseur utilise un point de référence au lieu d'un offset. Elle dit « donne-moi les lignes après celle-ci » et continue avec une clause WHERE ; il n'y a donc aucun parcours de lignes ignorées. Laravel chiffre la valeur du curseur et la place dans l'URL :
$posts = \App\Models\Post::query()
->where('published', true)
->orderByDesc('id')
->cursorPaginate(20);
La requête générée en coulisses ressemble à peu près à ceci ; pas d'offset, juste une comparaison indexée :
select * from posts
where published = 1 and id < 480
order by id desc
limit 21;
Règles importantes :
- Le tri est obligatoire et doit être stable : le champ
orderBydoit être unique (par exempleid). Si tu tries sur un champ aux valeurs en double, ajoute une seconde colonne de départage :orderBy('created_at')->orderBy('id'). - Pas de saut vers une page aléatoire : un curseur ne se déplace que vers l'avant/l'arrière ; tu ne peux pas proposer un lien « page 8 ». C'est idéal pour le défilement infini et les boutons « Charger plus ».
- Pas de total : comme
simplePaginate, il ne compte pas, ce qui le rend rapide.
Quand utiliser laquelle
La décision est en fait simple et se clarifie avec deux questions : l'utilisateur a-t-il besoin de sauter vers des pages numérotées, et quelle est la taille de la table ?
- Panneaux d'administration, résultats de recherche : numéros de page et totaux attendus →
paginate(). - Flux mobiles, fils sociaux, « Charger plus » : défilement infini vers l'avant →
cursorPaginate(). - Listes simples, très grande table mais sans numéros :
simplePaginate()ou curseur.
Règle générale : sur les tables atteignant des centaines de milliers de lignes, passe au curseur quand c'est possible ; mais si la navigation numérotée est réellement requise par le produit, reste sur l'offset et appuie tes requêtes sur des index.
Conseils API et performances
Si tu écris une API JSON, il suffit de retourner le paginator directement ; Laravel génère automatiquement les champs data, links et meta. Cela fonctionne aussi avec les API Resources :
return \App\Http\Resources\PostResource::collection(
Post::where('published', true)->cursorPaginate(20)
);
Quelques recommandations pratiques :
- Ajoute un index de base de données sur les colonnes que tu tries et filtres ; la vitesse du curseur en dépend.
- Ne sélectionne que les colonnes nécessaires :
select('id', 'title', 'created_at'). - Charge les données liées en eager-loading avec
with()pour ne pas exploser en N+1 requêtes par page. - Si la taille de page provient d'une saisie utilisateur, fixe une limite supérieure (par ex. 100 max).
Questions fréquentes
Puis-je afficher le nombre total de pages avec la pagination par curseur ?
Non. Comme le curseur (et simplePaginate) n'exécute pas de requête de comptage, il ne connaît ni le nombre total d'enregistrements ni de pages. Si tu as absolument besoin du total, utilise paginate() basé sur l'offset, ou effectue le comptage dans une requête séparée et mise en cache.
Puis-je transformer la sortie de cursorPaginate en liens de pages numérotés ?
Non. Par nature, un curseur ne peut se déplacer que vers les pages adjacentes (précédente/suivante) ; il ne peut pas sauter à une page aléatoire. Si la navigation numérotée est obligatoire, tu dois rester sur la pagination par offset.
Pourquoi l'offset ralentit-il sur de très grandes tables ?
La commande OFFSET N dit à la base de trouver les N premières lignes et de les jeter. Plus N grandit, plus le nombre de lignes à ignorer augmente, donc la charge de travail aussi. Le curseur, lui, démarre directement au bon endroit avec une clause WHERE indexée, et reste donc à vitesse constante.
Bien concevoir l'architecture de ta pagination est la base d'une application qui passe à l'échelle. Si tu veux discuter de la migration de l'offset vers le curseur dans ton projet, ou de l'optimisation de requêtes de liste lentes, contacte-moi.