php 75 lines · 9 steps

Cursor pagination in a Laravel API

A Laravel controller paginates posts by an opaque cursor instead of page numbers, avoiding offset drift.

Explained by highlit
1<?php
2 
3namespace App\Http\Controllers\Api;
4 
5use App\Http\Controllers\Controller;
6use App\Http\Resources\PostResource;
7use App\Models\Post;
8use Illuminate\Http\Request;
9 
10class PostController extends Controller
11{
12 public function index(Request $request)
13 {
14 $limit = min((int) $request->query('limit', 20), 100);
15 $cursor = $this->decodeCursor($request->query('cursor'));
16 
17 $query = Post::query()
18 ->where('status', 'published')
19 ->orderBy('created_at', 'desc')
20 ->orderBy('id', 'desc');
21 
22 if ($cursor !== null) {
23 $query->where(function ($q) use ($cursor) {
24 $q->where('created_at', '<', $cursor['created_at'])
25 ->orWhere(function ($q) use ($cursor) {
26 $q->where('created_at', '=', $cursor['created_at'])
27 ->where('id', '<', $cursor['id']);
28 });
29 });
30 }
31 
32 $posts = $query->limit($limit + 1)->get();
33 
34 $hasMore = $posts->count() > $limit;
35 $page = $posts->take($limit);
36 
37 $nextCursor = $hasMore
38 ? $this->encodeCursor($page->last())
39 : null;
40 
41 return PostResource::collection($page)->additional([
42 'meta' => [
43 'next_cursor' => $nextCursor,
44 'has_more' => $hasMore,
45 ],
46 ]);
47 }
48 
49 private function encodeCursor(Post $post): string
50 {
51 $payload = $post->created_at->toIso8601String() . '|' . $post->id;
52 
53 return rtrim(strtr(base64_encode($payload), '+/', '-_'), '=');
54 }
55 
56 private function decodeCursor(?string $cursor): ?array
57 {
58 if (empty($cursor)) {
59 return null;
60 }
61 
62 $decoded = base64_decode(strtr($cursor, '-_', '+/'), true);
63 
64 if ($decoded === false || !str_contains($decoded, '|')) {
65 abort(400, 'Invalid pagination cursor.');
66 }
67 
68 [$createdAt, $id] = explode('|', $decoded, 2);
69 
70 return [
71 'created_at' => $createdAt,
72 'id' => (int) $id,
73 ];
74 }
75}
01 / 01
STEP 01

Walkthrough

Space play step click any line
Three takeaways
  1. 1Keyset pagination uses the last row's sort keys as a bookmark, so it stays correct even as rows are inserted.
  2. 2Fetching one extra row is a cheap way to know whether a next page exists without a separate count query.
  3. 3Encoding the cursor as base64url keeps the sort state opaque and URL-safe while remaining trivially decodable server-side.

Related explainers

Share this explainer

Here's the card — post it anywhere.

Cursor pagination in a Laravel API — share card
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code