Skip to content

Pagination ​

Deux patterns sont disponibles pour paginer les endpoints de liste : OFFSET et curseur (keyset). Choisissez en fonction du volume de données et des exigences UI.

Comparaison rapide ​

OFFSETCurseur
ImplémentationSimpleModérée (pattern fetch+1)
Nombre totalNécessite COUNT(*)Pas nécessaire
Vitesse sur pages profondesSe dégrade linéairementConstante (seek d'index)
UI avec numéro de pageFacileDifficile
Défilement infini / fluxFragile (dérive de lignes)Stable
Changements de données pendant la navigationPeut causer une dérive de lignesStable

Règle empirique : Utiliser OFFSET pour les tables admin avec numéros de pages et petits datasets. Utiliser le curseur pour les flux, le défilement infini et toute table avec plus de ~10 000 lignes.

Pagination OFFSET ​

php
private function listByOffset(ServerRequestInterface $request): ResponseInterface
{
    $limit  = max(1, min(100, QueryStringParser::int($request, 'limit', 20) ?? 20));
    $offset = max(0, QueryStringParser::int($request, 'offset', 0) ?? 0);

    $items = $repo->fetchAll(
        'SELECT * FROM articles ORDER BY id DESC LIMIT ? OFFSET ?',
        [$limit, $offset],
    );
    $total = $repo->fetchOne('SELECT COUNT(*) AS cnt FROM articles', [])['cnt'] ?? 0;

    return $json->create([
        'items'       => $items,
        'limit'       => $limit,
        'offset'      => $offset,
        'total'       => (int) $total,
        'has_more'    => ($offset + $limit) < (int) $total,
        'next_offset' => ($offset + $limit) < (int) $total ? $offset + $limit : null,
    ]);
}

Pourquoi OFFSET devient plus lent : La base de données doit scanner et ignorer toutes les lignes avant l'offset. Pour OFFSET 5000, le moteur lit 5001 lignes et jette les 5000 premières. Vous pouvez vérifier avec SQLite :

sql
EXPLAIN QUERY PLAN
SELECT * FROM articles ORDER BY id DESC LIMIT 20 OFFSET 5000;
-- SCAN articles USING INDEX idx_articles_id_desc
-- Le scan touche toujours 5020 lignes.

Pagination par curseur ​

Le curseur est l'id de la dernière ligne vue. Chaque page récupère les lignes "avant" le curseur (pour l'ordre descendant) avec WHERE id < cursor, que l'index sert avec un seek — aucune ligne avant le curseur n'est touchée.

php
private function listByCursor(ServerRequestInterface $request): ResponseInterface
{
    $limit   = max(1, min(100, QueryStringParser::int($request, 'limit', 20) ?? 20));
    $afterId = QueryStringParser::int($request, 'after'); // null = première page

    // pattern fetch+1 : détecter has_more sans requête COUNT
    $fetch = $limit + 1;

    if ($afterId === null) {
        $rows = $repo->fetchAll(
            'SELECT * FROM articles ORDER BY id DESC LIMIT ?',
            [$fetch],
        );
    } else {
        $rows = $repo->fetchAll(
            'SELECT * FROM articles WHERE id < ? ORDER BY id DESC LIMIT ?',
            [$afterId, $fetch],
        );
    }

    $hasMore = count($rows) > $limit;
    if ($hasMore) {
        array_pop($rows); // supprimer la ligne sentinelle supplémentaire
    }

    $nextCursor = $hasMore && $rows !== [] ? (int) end($rows)['id'] : null;

    return $json->create([
        'items'       => $rows,
        'limit'       => $limit,
        'has_more'    => $hasMore,
        'next_cursor' => $nextCursor,
    ]);
}

Le pattern fetch+1 ​

Pour savoir s'il y a une page suivante sans émettre un COUNT(*) :

  1. Demander limit + 1 lignes.
  2. Si le résultat a plus de limit lignes, il y a une page suivante.
  3. Supprimer la dernière ligne (array_pop) avant de retourner.
  4. Utiliser l'id de la dernière ligne restante comme next_cursor.

Cela évite une requête supplémentaire au prix de toujours récupérer une ligne de plus.

Utilisation côté client ​

GET /articles/cursor?limit=20
→ { items: [...20 articles], has_more: true, next_cursor: 42 }

GET /articles/cursor?limit=20&after=42
→ { items: [...20 articles], has_more: true, next_cursor: 22 }

GET /articles/cursor?limit=20&after=22
→ { items: [...2 articles], has_more: false, next_cursor: null }

Limitation de la plage de limite ​

Toujours limiter la plage de limite à une valeur raisonnable pour éviter les requêtes non bornées :

php
$limit = max(1, min(100, QueryStringParser::int($request, 'limit', 20) ?? 20));

Cela accepte 1–100 et utilise 20 par défaut quand le paramètre est absent.

Quand passer de OFFSET au curseur ​

Un guide approximatif basé sur la taille de la table et la profondeur de page typique :

LignesProfondeur typiqueRecommandation
< 10 000N'importe laquelleL'un ou l'autre fonctionne ; OFFSET est plus simple
10 000–100 000Superficielle (page 1–5)L'un ou l'autre ; ajouter un index sur la colonne de tri
10 000–100 000Profonde (page 10+)Curseur préféré
> 100 000N'importe laquelleCurseur fortement recommandé

Ajoutez un index sur votre colonne de tri quelle que soit l'approche choisie :

sql
CREATE INDEX idx_articles_id_desc ON articles (id DESC);

Comparaison des résultats à la même position ​

Lors de la migration de OFFSET vers curseur, vérifiez la correction en récupérant la même "fenêtre" de lignes des deux façons :

php
// OFFSET : lignes 11–20 (offset=10 à base 0)
$offsetPage = $get('/articles/offset?limit=10&offset=10');

// Curseur : récupérer l'id en position 10 (offset=9), l'utiliser comme ancre
$anchor     = $get('/articles/offset?limit=1&offset=9');
$anchorId   = $anchor['items'][0]['id'];
$cursorPage = $get("/articles/cursor?limit=10&after={$anchorId}");

// Ces résultats doivent être identiques
assert(array_column($offsetPage['items'], 'id') === array_column($cursorPage['items'], 'id'));

Publié sous licence MIT.