Laravel Pagination at Scale: Choosing Between paginate(), simplePaginate(), and cursorPaginate()
Offset pagination slows linearly with page depth and duplicates rows under concurrent writes. Here's how to decide between paginate(), simplePaginate(), and cursorPaginate(), with a working API implementation and validation checks.
29 May 2026, 08:47 UTC

Your admin table worked fine at 5,000 rows. At 500,000, page 40 takes seconds to load, and your API feed shows the same record twice whenever someone posts while a client scrolls. Both symptoms come from the same root cause: offset pagination. Laravel ships three paginators, and picking the right one is mostly a question of table size, write frequency, and whether your UI needs page numbers.
The decision and its constraints
Offset pagination (paginate() and simplePaginate()) translates "page N" into OFFSET (N-1) * per_page. The database must scan and discard those rows before returning yours, so cost grows linearly with page depth. Cursor pagination (cursorPaginate()) instead encodes the last-seen value of an ordered column and issues WHERE id > ? LIMIT 25, which uses an index and stays roughly constant per page regardless of depth.
The trade-off: cursors only support forward/backward traversal. There are no page numbers, no "jump to page 37," and no total count. Cursor pagination also demands a deterministic, unique ordering — typically the primary key, or a column plus an id tiebreaker — or rows can be skipped or duplicated when values tie.
Comparing the three paginators
| Concern | paginate() | simplePaginate() | cursorPaginate() |
|---|---|---|---|
| Total count / page numbers | Yes (runs COUNT) | No | No |
| Deep-page performance | Degrades linearly | Degrades linearly | Roughly constant (indexed) |
| Stable under concurrent writes | No — rows shift | No — rows shift | Yes — anchored to a value |
| Jump to arbitrary page | Yes | Next/previous only | Next/previous only |
| Best fit | Admin grids, reports | Small lists, blogs | Feeds, APIs, exports |
Note the middle row: simplePaginate() only saves you the COUNT query. It still uses OFFSET, so it does not fix deep-page slowness or write-race duplication. The COUNT itself can also be expensive on large, heavily filtered tables — if you need totals there, consider caching the count or showing an approximation.
Why writes break offset pages
Offset pagination anchors to a position. If a new row is inserted at the top of the ordering between a client's page 1 and page 2 requests, every subsequent row shifts down one position — the last row of page 1 reappears at the top of page 2. Deletes create gaps instead. Cursor pagination anchors to a value ("everything after id 4821"), so inserts and deletes elsewhere in the table cannot shift what the next page returns.
Implementing cursor pagination in an API
A typical JSON endpoint. Run this in a controller; no special permissions beyond your normal database access are needed:
use App\Models\Event;
use Illuminate\Http\Request;
public function index(Request $request)
{
$paginator = Event::query()
->orderBy('id') // unique, indexed ordering column
->cursorPaginate(25); // reads ?cursor= automatically
return response()->json([
'data' => $paginator->items(),
'next_cursor' => $paginator->nextCursor()?->encode(),
'has_more' => $paginator->hasMorePages(),
]);
}
The client passes the returned next_cursor back as ?cursor=.... Cursor strings are opaque and URL-safe, but they encode position only — they are not authorization. Apply your usual scoping (e.g., where('tenant_id', ...)) in the query.
If you order by a non-unique column such as created_at, add a tiebreaker so the ordering is total:
Event::orderBy('created_at')->orderBy('id')->cursorPaginate(25);
Multi-column cursor support is version-sensitive, so check the CursorPaginator source or the pagination docs for your installed Laravel version before relying on it. Whichever columns you order by, make sure an index covers them — a composite index on (created_at, id) in this example — or the cursor query degrades into a scan.
Validating the choice
Three practical checks:
- Measure depth cost. Seed a test table with ~100k rows. With
DB::listen()(or Telescope/Debugbar in a dev environment), time page 1 versus page 500 withpaginate(), then compare with the equivalent cursor requests. You should see offset cost grow with depth while cursor cost stays flat. - Check the query plan. Run
EXPLAINon the generated SQL in your database client. The cursor query should show an index range scan on the ordering column, not a full scan with filesort. - Test the write race. Write a feature test that fetches page 1, inserts a row, then fetches the next page. Assert the cursor version returns no duplicates or gaps; the offset version may legitimately fail this assertion, which is the point.
Limitations to keep in mind
Cursor pagination is not a free upgrade. You lose total counts and page-number UIs, which many admin tools genuinely need. Sorting is constrained to the cursor columns — user-selectable "sort by any column" grids are a poor fit. And an unindexed or non-unique ordering silently undermines both its performance and its correctness. The pragmatic rule: paginate() for small-to-medium admin UIs that need totals and page jumps, simplePaginate() when totals are unneeded but tables stay modest, and cursorPaginate() for high-volume feeds, exports, and public APIs on large, frequently-written tables.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.