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

Cursor Pagination Laravel Package

juampi92/cursor-pagination

Cursor-based pagination for Laravel Eloquent, inspired by Stripe. Encode and decode opaque cursors with ease, keeping pagination stable and efficient. Perfect for large datasets, infinite scrolling, and APIs that need predictable next/previous navigation.

View on GitHub
Deep Wiki
Context7

Getting Started

The package is a drop-in replacement for paginate() on endpoints where offset pagination is too expensive or too inconsistent: activity feeds, infinite scroll, "load more" lists, and any table that gets written to while a client is walking it.

Install and wire up

composer require juampi92/cursor-pagination

Laravel 5.5+ auto-discovers Juampi92\CursorPagination\CursorPaginationServiceProvider. On older versions add it to config/app.php. Publish the config if you want to change the query-string parameter name:

php artisan vendor:publish --provider="Juampi92\CursorPagination\CursorPaginationServiceProvider"

Where to look first

  • src/CursorPagination.php — the trait/macro that adds cursorPaginate() to the Eloquent and query builders.
  • src/CursorPaginator.php — the paginator itself; this is what you get back from the call and what decides next_cursor / prev_cursor.
  • config/cursor-pagination.php — cursor parameter name.
  • README.md — the response shape and the orderBy contract, which is the one thing you must get right.

First use case

Take an existing listing endpoint and swap the paginator:

// before
Route::get('/users', fn () => User::paginate(20));

// after
Route::get('/users', fn () => User::orderBy('id')->cursorPaginate(20));

The JSON looks like:

{
  "data": [ ... ],
  "per_page": 20,
  "next_cursor": "MjA=",
  "prev_cursor": null,
  "next_page_url": "https://api.test/users?cursor=MjA=",
  "prev_page_url": null
}

The client stores next_cursor and calls ?cursor=MjA= until it comes back null. That is the whole protocol.

Implementation Patterns

Stable sort key, single column. The cursor is derived from the value of the column you ordered by — in practice the primary key. Treat id as the default and only deviate when you have a genuinely monotonic, unique column:

Post::where('published', true)
    ->orderBy('id')
    ->cursorPaginate(25);

Descending feeds. Newest-first is the common case; the package handles desc by comparing with < and exposing a prev_cursor so the client can walk back:

Post::orderByDesc('id')->cursorPaginate(25);

Query builder and joins. The trait lands on the query builder, so raw table queries work too — handy for reporting endpoints where you don't want a model:

DB::table('events')
    ->join('users', 'users.id', '=', 'events.user_id')
    ->orderBy('events.id')
    ->cursorPaginate(50);

Eager loading and filters are unchanged. with(), withCount(), when(), and scopes all compose normally, because the paginator just applies a where before running the query. Keep filter parameters in the URL the client echoes back alongside cursor:

$query = Order::with('customer')
    ->when($request->status, fn ($q, $s) => $q->where('status', $s))
    ->orderBy('id');

return $query->cursorPaginate($request->integer('per_page', 20));

API Resources. Return the paginator straight from the controller for plain JSON, or wrap it in a resource collection — Laravel's resource layer recognises paginators and emits the cursor links. If you need a custom envelope, read $paginator->nextCursor() / previousCursor() and build the links yourself rather than string-parsing next_page_url.

Client contract. One rule in the frontend: never construct a cursor, always echo the last one you received. That makes the format an implementation detail you can change later.

Testing. Cursors are deterministic when factories use explicit IDs; assert on the boundary rather than the encoded string:

$this->getJson('/api/users?cursor='.$page1->nextCursor())
    ->assertJsonCount(20, 'data')
    ->assertJsonPath('data.0.id', 21);

Gotchas and Tips

This package is archived — plan the exit before you build on it. The last release was November 2020, and Laravel 8.37+ ships cursorPaginate() natively, with multi-column cursors (orderBy('created_at')->orderBy('id')), CursorPaginator::hasMorePages(), and first-class resource support. The method name and call site are identical, so the migration is mostly deleting the dependency and updating assertions on the response shape: native paginators under resources emit meta.next_cursor / links.next instead of the package's top-level next_cursor / next_page_url. Budget for that JSON diff if external clients are already consuming this package's keys.

Only one sort column, and it must be unique. Multi-column ordering is not supported, and orderBy('created_at') with ties will silently skip or duplicate rows. If you need a secondary sort, either add a dense unique column or accept id as the cursor and sort in the client.

No count, by design. There is no total(), no lastPage(), no page numbers. Any UI that shows "page 3 of 20" or a numbered pager must be redesigned or that endpoint must stay on paginate().

Index for it. The cursor predicate is a range scan on the cursor column, combined with your filters. A composite index in filter-then-cursor order — (status, id) for the example above — is what keeps deep pages fast. Without it you have traded an offset scan for a different slow query.

Validate the cursor. It's an encoded model key, not a signed token; a malformed value should be treated as "start from the beginning" (or a 422 you control) rather than allowed to blow up the cast. Guard the parameter and cap per_page — clients will happily ask for 10,000.

Consistency is weaker than you think. Deletes between pages are handled gracefully, but rows inserted before the current cursor position will never appear in that pass. That's fine for feeds and unacceptable for exports — use chunking (chunkById) for the latter.

Extension points if you must stay. Subclass CursorPaginator to override URL generation or add metadata, and register a macro on the query builder if you need a second named paginator (e.g. cursorPaginateJson) alongside the default. Keep those overrides thin so the eventual swap to native cursor pagination is a matter of deleting a file.

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.
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
xeviant/laravel-paystack
gorilladash/laravel-website-sdk
martialbe/flysystem-oss
chondal/3312client
jeroen-g/flickr
opscale-co/nova-genbi
itsjustvita/laravel-retention
tequilarapido/trackit
gregsphotography/site-builder-filament
cabaoctavio/laravel-query-log