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.
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:
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.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.pagination() on an existing schema — this is the single line you will change to opt an endpoint in.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.
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:
links.next / links.prev and page[cursor] / page[size] in the URL; links.last and meta.page.total are absent.?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.
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.
How can I help you explore Laravel packages today?