Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Cursor Paginator Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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:

  1. 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.
  2. 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.

Implementation Patterns

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.

Gotchas and Tips

  • Archived and unmaintained. No fixes for new PHP/Laravel versions, no security patches. Treat it as reference code you may need to fork into app/Support/ rather than a dependency you pin. On Laravel 8+ there is no reason to install it at all.
  • No total count and no page numbers. Cursor pagination is O(1) precisely because it does not count. If a UI needs "page 3 of 47" or a total, this is the wrong tool — use paginate() for admin tables and reserve cursors for feeds.
  • Cursors are opaque, not trusted. Decode/validate before use. A malformed or tampered cursor should yield a 400 or a fresh first page, not a 500 or an unbounded scan. Wrap the call and catch decode failures.
  • Changing 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.
  • Mutations between pages cause skips and duplicates. Deleting or updating rows ahead of the cursor shifts the window. Accept this (it is inherent to keyset pagination and usually invisible in a feed) or snapshot the result set for exactness.
  • 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.
  • Eager loading still matters. Cursor pagination solves the deep-offset problem, not the N+1 problem. with() the relations you render.
  • Debugging: log the decoded cursor alongside the generated SQL (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.
  • Extension points: the macro registration in the service provider is the place to hook if you need a custom cursor encoder (e.g. signed cursors to prevent enumeration) or a custom paginator that adds has_more/prev_cursor to the response. Because the package is archived, prefer subclassing or copying into your app namespace over patching vendor/.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
adachsoft/changelog-linter
adachsoft/collection
payerurl/telegrambot-crypto-payment-instant-payout
dskripchenko/laravel-hybrid-search
riorizkyrainey/lara-ced
mehedimi/static-vel
zharp/zharp
nonsapiens/bigquery-model-sync
lgu-bayambang-ict-office/lgu-laravel-development-standards
andydefer/laravel-support
se7enxweb/flysystem
lampwick/laravel
jeslage/statamic-emojis
micahdshackelford/rmap-laravel
draftsmaninc/draftsman
square-bit/laravel-volition
himito/laravel-mailman
htinlynn/genius-helper
maco-studios/hephaestus
elmogy/larafast