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

laravel-json-api/cursor-pagination

Cursor-based pagination for Laravel JSON:API. Adds a cursor paginator so your JSON:API endpoints can page through large datasets efficiently, avoiding offset limits and keeping results stable as records change.

View on GitHub
Deep Wiki
Context7

Getting Started

Preconditions. This package only makes sense if the service already exposes a JSON:API surface through laravel-json-api (laravel-json-api/core, laravel-json-api/laravel). Outside that stack there is no integration surface — call Laravel's native cursorPaginate() directly and stop reading.

Install and pin.

composer require laravel-json-api/cursor-pagination

Pin both this package and the parent to exact versions. Three stars and zero dependents means there is no external CI signal; a floating constraint is how you find out about a Page contract change in production.

Where to look first, in order:

  1. vendor/laravel-json-api/cursor-pagination/src — it is small. Read the paginator and the Page implementation end to end before wiring anything; you are effectively adopting this source, so treat it as a code review, not a black box.
  2. LaravelJsonApi\Contracts\Pagination\Paginator and the Page contract in the parent — this is the seam you integrate against and the thing that can drift on upgrade.
  3. pagination() on an existing schema — this is the single line you will change to opt an endpoint in.
  4. Your endpoint's sorting() defaults and its database index for that sort tuple.

First use case — one endpoint, not the fleet. Pick a high-volume, append-mostly, sequentially consumed collection: an activity feed, notification list, or audit log. Define a deterministic sort with a unique tiebreaker (created_at, id) before you touch pagination, add the matching composite index, and only then flip the schema:

// app/JsonApi/V1/Activities/Schema.php
public function pagination(): ?Paginator
{
    return CursorPaginator::make(); // package-supplied cursor paginator
}

public function defaultSort(): array
{
    return ['-created_at', '-id'];
}

Smoke test. GET /api/v1/activities?page[size]=2, follow links.next verbatim to the end, and confirm you land on exactly the rows a plain ORDER BY created_at DESC, id DESC returns with no duplicates. Then do it again while inserting rows mid-scroll — that is the actual behaviour you are buying.

Implementation Patterns

Per-schema opt-in. Keep the framework's offset pagination on everything else. The pagination() method on the schema is your switch: return the cursor paginator for opted-in resources, leave it alone (or return null) everywhere else. This is what makes rollout reversible.

Register the sort tuple as part of the endpoint contract. A cursor is a position in a sort order; if the client can change the sort, the cursor is meaningless. Either expose only a fixed default sort on cursor endpoints, or validate incoming ?sort= against the sort the cursor was issued for and return a 400 on mismatch. Do not let the package silently page through a reordered set.

// Reject cursors when the requested sort differs from the issued sort.
if ($request->has('sort') && $request->query('sort') !== $this->canonicalSort) {
    throw new QueryException(...); // map to a JSON:API 400 error object
}

Keep filtering and pagination orthogonal. Cursors do not embed filter state. If a client changes filter[] mid-scroll, you will get confusing-but-valid results rather than an error. Either document that a filter change means restarting from the first page, or hash the active filter set into the cursor in your own layer and reject mismatches.

Clamp page[size]. The package will honour what the client sends. Clamp in a middleware or a base schema (min($size, 100)) so a hostile or naive client cannot ask for 10 000 rows with a deep join.

Wrap it behind your own abstraction before the second endpoint. Put the paginator behind a small app-level class or trait and register that on schemas, rather than importing the package class into twenty files. If you later vendor, fork, or replace it with your own signed-cursor implementation, the change is one file:

namespace App\JsonApi\Pagination;

class CursorPaginator
{
    public static function make(int $default = 30): \LaravelJsonApi\Contracts\Pagination\Paginator
    {
        return \LaravelJsonApi\CursorPagination\CursorPaginator::make($default);
    }
}

Test the contract, not the class. Three assertions earn their keep on every cursor endpoint:

  • Link shape: response has links.next / links.prev and page[cursor] / page[size] in the URL; links.last and meta.page.total are absent.
  • Stability: walk all pages while inserting and deleting rows between requests; assert the union of ids has no duplicates and no gaps in the original snapshot.
  • Error mapping: ?page[cursor]=not-a-cursor returns a JSON:API errors document with status 400, not a 500 and not an HTML stack trace.

Rollout. Ship behind a feature flag or a new API version. Note in the changelog that total and last are gone by design — front-end teams will assume they exist and will build a paginator widget around them.

Vendoring. With no community validation, the realistic long-term posture is a fork under an internal namespace with a named owner. Because the integration surface is the parent's Page contract, a fork is invisible to clients — which is exactly why you should keep the app-level wrapper from the start.

Gotchas and Tips

total and last do not exist. Not a bug — a keyset paginator cannot count without a scan. If a client needs "page 7 of 42", this endpoint is the wrong endpoint. Say so in the API docs and in the PR description.

The package cannot enforce a unique sort key. If created_at has duplicates and you omit id as a tiebreaker, you will still skip and duplicate rows — the exact bug cursor pagination is supposed to fix. Every cursor endpoint needs a unique, non-null tuple, and a composite index matching it exactly (same column order, same direction) or you replace an O(n) offset with a filesort.

Verify whether cursors are tamper-evident before you ship. If cursors are simply base64-encoded sort values, a client can decode, edit, and re-encode one to position outside their authorized filter set. That is an authorization question, not a polish item. Decode a cursor in php artisan tinker, and if it is plaintext, either sign it in your wrapper or confine it to endpoints where the filter is applied server-side regardless of the cursor.

Malformed and stale cursors must be 400s. Laravel's Cursor::decode throws on bad input, and that surfaces as a 500 through the framework's default handler. Catch it and convert to a JSON:API error object. Add the test — this is the single most common production noise source for cursor endpoints.

Sort mismatch is silent, not loud. A cursor from created_at applied to a ?sort=title query does not throw; it produces nonsense. Enforce the sort, do not trust the client.

NULL semantics and driver differences. NULL ordering differs across MySQL, PostgreSQL, and SQLite, and collation differences change string-key ordering. If your sort tuple can be nullable, normalise it (COALESCE) or make it non-null. Test on the same driver you deploy to.

End-of-list looks like a bug. The final page returns links.next: null. Make sure your front-end checks for null rather than for data.length < size, which breaks the moment a page comes back short for other reasons.

Includes change cost, not semantics. Compound documents and ?include= still work, but a deep include on a keyset query is where slow pages reappear. Profile the query log after adding includes; look for the tuple comparison (where (created_at, id) < (?, ?)) appearing as an index range scan rather than a filter.

Relationship endpoints are the first thing to test. Confirm the package covers related-resource collections and nested filters, or you will ship inconsistent pagination across an API where clients cannot tell which is which.

Upgrading the parent is a manual review. laravel-json-api has broken its contracts across majors, and this package is a thin companion — it is precisely what goes stale first. Add a CI job that installs the parent's next minor and runs your characterization tests. When it goes red, you have a day's notice instead of an incident.

Debugging tip. Dump the decoded cursor and the generated SQL side by side for a failing page. Almost every "cursor bug" is a non-unique sort, a sort mismatch, or a missing index — never the encoding.

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.
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource