amrnn/laravel-cursor-paginator
Cursor-based pagination for Laravel. amrnn/laravel-cursor-paginator provides a paginator that uses cursors instead of offsets, useful for APIs and large datasets where stable, efficient paging matters. A small, focused package for Eloquent queries.
Install and confirm the entry point first.
composer require amrnn/laravel-cursor-paginator
The package is small and archived, so read the source before trusting the README. Two things to check in this order:
src/ — locate the service provider (CursorPaginatorServiceProvider or similar). If it registers a query-builder macro, you get the functionality on every Eloquent/DB query with zero model changes. If it ships a trait, you must use it on each model.composer.json — check the illuminate/* constraint. If it targets Laravel 5.x/6.x, it will not install cleanly on modern Laravel without --ignore-platform-reqs, and even then the macro registration may collide with Laravel 8+'s native cursorPaginate().First use case: an infinite-scroll or "load more" feed over a large table where OFFSET pagination gets slow at depth and where users never jump to page 7 — a timeline, activity log, audit trail, or chat history.
A minimal first call (verify the exact signature against the source — it is typically cursorPaginate($perPage, $cursor = null, $column = 'id')):
$posts = Post::query()
->orderBy('id')
->cursorPaginate(20, request('cursor'));
return response()->json([
'data' => $posts->items(),
'next_cursor' => $posts->nextCursor(), // null when exhausted
]);
Wire it into a route, hit it with no cursor, copy the returned cursor into the next request, and confirm you get the following 20 rows with no overlap. That round trip is the whole contract of the package — get it green before building anything on top.
Controller/action shape for a JSON API. Keep cursor extraction and response shaping in one reusable place so every list endpoint behaves identically:
class FeedController extends Controller
{
public function __invoke(Request $request)
{
$request->validate(['cursor' => 'nullable|string', 'limit' => 'nullable|integer|min:1|max:100']);
$posts = Post::query()
->with('author:id,name') // eager load; cursor queries still N+1 if you forget
->orderByDesc('created_at')
->orderByDesc('id') // tiebreaker — see Gotchas
->cursorPaginate($request->integer('limit', 20), $request->query('cursor'));
return response()->json([
'data' => PostResource::collection($posts->items()),
'next_cursor' => $posts->nextCursor(),
'has_more' => $posts->hasMorePages(),
]);
}
}
Stable ordering with a tiebreaker. Never paginate on a non-unique column alone. created_at collides constantly under bulk inserts, which causes rows to be skipped or duplicated across pages. Always append the primary key:
->orderByDesc('created_at')->orderByDesc('id')
The order direction must match the cursor's assumption. If the package builds WHERE (col, id) < (?, ?) for descending order, flipping orderBy to ascending mid-stream silently returns garbage.
Deterministic filters. Every where clause applied on the first request must be applied identically on every subsequent request. The cursor encodes a position in a result set; if the filter set changes, that position means something different.
$query = Post::query()->where('status', 'published');
if ($category = $request->query('category')) {
$query->where('category_id', $category);
}
// pass $category back to the client (or embed it in the cursor URL) so page 2 uses the same filter
Infinite scroll on the front end. The client only ever needs to hold next_cursor:
let cursor = null;
async function loadMore() {
const url = new URL('/api/feed', location.origin);
if (cursor) url.searchParams.set('cursor', cursor);
const res = await fetch(url).then(r => r.json());
cursor = res.next_cursor;
render(res.data);
if (!cursor) observer.disconnect();
}
Reusable base trait/controller. Because the pattern is identical for every model, extract a CursorListAction or a CursorPaginates trait that takes a query, a whitelist of sortable columns, and the request, and returns the JSON envelope. This is where most of the day-to-day leverage lives — the package itself is only ~100 lines.
Migration path to native Laravel. If you are on Laravel 8+, replace the package entirely with the built-in implementation; the call signature and response shape are close enough that it is usually a find-and-replace:
$posts = Post::orderByDesc('created_at')->orderByDesc('id')->cursorPaginate(20);
// $posts->nextCursor(), $posts->items(), $posts->hasMorePages()
Native also handles the cursor query parameter automatically via CursorPaginator::resolveCurrentCursor(), which the package was written before.
app/Support/ rather than a dependency you pin. On Laravel 8+ there is no reason to install it at all.paginate() for admin tables and reserve cursors for feeds.limit mid-stream is safe; changing orderBy is not. The cursor encodes values from the sort columns, not an offset, so limit changes are fine. Reordering the orderBy clauses invalidates every outstanding cursor.select() must include every cursor column. ->select('id', 'title') while ordering by created_at breaks the generated WHERE clause. Include all sort columns, or skip the projection.with() the relations you render.DB::listen) for the first request and the second. Nine times out of ten the bug is a mismatched orderBy direction or a missing tiebreaker, which the SQL makes obvious.has_more/prev_cursor to the response. Because the package is archived, prefer subclassing or copying into your app namespace over patching vendor/.How can I help you explore Laravel packages today?