PHP cookbook · Intermediate
Paginate large tables with cursors in PHP
List orders efficiently in an API without slow OFFSET queries on large tables.
Approach
Use keyset (cursor) pagination: order by a unique, indexed key (e.g., created_at, id) and fetch rows after the last seen key. Encode the last row's key in an opaque, base64 cursor. Fetch limit + 1 rows to know whether there's a next page.
Solution
<?php
declare(strict_types=1);
function listOrders(PDO $pdo, int $userId, int $limit, ?string $cursor): array
{
$limit = max(1, min($limit, 100));
$params = ['user' => $userId, 'limit' => $limit + 1];
$where = 'user_id = :user';
if ($cursor !== null) {
[$createdAt, $id] = json_decode(base64_decode($cursor, true) ?: 'null', true) ?? throw new InvalidArgumentException('Bad cursor');
$where .= ' AND (created_at, id) < (:created_at, :id)';
$params += ['created_at' => $createdAt, 'id' => $id];
}
$stmt = $pdo->prepare("SELECT id, total_cents, created_at FROM orders WHERE {$where} ORDER BY created_at DESC, id DESC LIMIT :limit");
foreach ($params as $k => $v) {
$stmt->bindValue($k, $v, is_int($v) ? PDO::PARAM_INT : PDO::PARAM_STR);
}
$stmt->execute();
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
$next = null;
if (count($rows) > $limit) {
array_pop($rows);
$last = end($rows);
$next = base64_encode(json_encode([$last['created_at'], (int) $last['id']]));
}
return ['items' => $rows, 'next_cursor' => $next];
}
// Index: CREATE INDEX idx_orders_user_created ON orders (user_id, created_at DESC, id DESC);
Pitfalls
- Ordering by a non-unique column only (ties cause skipped or duplicated rows).
- Missing a matching composite index.
- Row-value comparison
(a, b) < (x, y)isn't optimized the same in every database — test with EXPLAIN (MySQL may need the expanded OR form).
Variations
- Offer offset pagination only for small admin tables where jumping to page N matters.
- Sign cursors (HMAC) if you don't want clients to tamper with them.
Further reading: Use The Index, Luke: No offset
Reference: https://use-the-index-luke.com/no-offset
More PHP recipes
- Add CSRF protection to PHP forms
- Add health check endpoints to a PHP app
- Add structured logging with request IDs in PHP
- Build a secure password reset flow in PHP
- Cache expensive queries with Redis in PHP
- Call external APIs with timeouts and retries in PHP
- Handle file uploads securely in PHP
- Implement rate limiting in a PHP API