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
‹ swipe to step through ›
Walkthrough
Space play
←→ step
click any line
Three takeaways
- 1Keyset pagination uses the last row's sort keys as a bookmark, so it stays correct even as rows are inserted.
- 2Fetching one extra row is a cheap way to know whether a next page exists without a separate count query.
- 3Encoding the cursor as base64url keeps the sort state opaque and URL-safe while remaining trivially decodable server-side.
Related explainers
php
<?php namespace App\Queue;
A stable priority job queue in PHP
priority-queue
data-structures
closures
Intermediate
8 steps
php
<?php namespace App\Support;
Normalizing phone numbers to E.164 in PHP
input-validation
normalization
regular-expressions
Intermediate
8 steps
javascript
class InfiniteScroll { constructor(sentinel, { loadMore, root = null, rootMargin = '200px' } = {}) { this.sentinel = sentinel; this.loadMore = loadMore;
Infinite scroll with IntersectionObserver
intersectionobserver
pagination
async
Intermediate
10 steps
php
final class Route { private string $regex; private array $paramNames = [];
Compiling route patterns into regex in PHP
routing
regex
parsing
Intermediate
8 steps
php
public function customerTotals(Request $request): Collection { return DB::table('orders') ->join('customers', 'customers.id', '=', 'orders.customer_id')
Building an aggregate report with Laravel's query builder
query builder
aggregation
conditional queries
Intermediate
8 steps
python
from flask import Blueprint, request, url_for, jsonify, abort from flask.views import MethodView from .models import Article, db
Building a paginated JSON API with Flask MethodView
rest-api
pagination
class-based-views
Intermediate
10 steps
Share this explainer
Here's the card — post it anywhere.
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code
Embed this explainer
Drop the interactive walkthrough into a blog or docs. Views never cost a credit.
<iframe src="https://highlit.co/explainers/cursor-pagination-in-a-laravel-api-explained-php-6cc6/embed?autoplay=1" width="100%" height="520" loading="lazy" style="border:0"></iframe>
Autoplay is on by default — add ?autoplay=0 to start paused.