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.
Architecture fit — binary, not incremental. This package is a pagination-profile add-on to the laravel-json-api server stack. It supplies a cursor-based Page implementation so endpoints can emit page[cursor] / page[size] and spec-shaped links/meta instead of the framework's default offset strategy. If the product has already committed to a JSON:API surface on that framework, it slots in cleanly. If the stack is API Resources, Fractal, spatie/laravel-query-builder, GraphQL, or plain REST, there is no integration surface — Laravel's native cursorPaginate() is the correct primitive and this package is irrelevant. The program framing matters: this package supports an existing architectural commitment; it must never appear in roadmap discussion as an input to "should we do JSON:API." Keep the ticket scoped as "how do we paginate the JSON:API surface we already have," or it will get caught up in a much larger and unrelated debate.
The product trade it plugs into. Cursor pagination buys stable, cheap deep pages at the cost of no jump-to-page, no arbitrary page addressing, and no cheap totals. That is a client-contract decision made per surface, not a global default: sequential consumers (mobile infinite scroll, background sync, exports, activity and audit logs) are a clear yes; admin tables with sortable columns and "page 7 of 42" are a clear no. A TPM should force that surface-by-surface decision before the package is evaluated on its merits, otherwise the evaluation becomes a proxy argument about UX nobody has actually had.
Integration feasibility — narrow code, wide process. The code surface is a pagination class plus per-resource registration; the parent framework owns content negotiation, serialization, error rendering, and link generation. A competent engineer wires one endpoint in under a day. The friction is contractual and lives in application code, not the package:
created_at, id) must be mandated per endpoint. The package cannot enforce the tiebreaker; without it, keyset pagination produces exactly the skipped and duplicated rows it was adopted to prevent.?sort= differs from the sort embedded in the cursor.400 error object, not a 500.Size this as N endpoints × per-endpoint review, not as one installation.
Technical risk — provenance, not code quality. Three stars, zero dependents, and a single maintainer mean effectively no field validation: no evidence of production use at scale, no community-reported edge cases, no bus factor. Four risks shape the plan:
| Risk | Impact | Mitigation |
|---|---|---|
Parent version drift on the Page contract |
Every laravel-json-api major becomes a manual compatibility review — a recurring program tax |
Vendor/fork under an internal namespace; CI job against parent's next minor |
| Keyset correctness traps | NULL semantics, non-unique sort keys, cursors over joins/aliased columns, MySQL/PostgreSQL/SQLite collation differences |
Characterization tests per endpoint; index sign-off |
| Cursor opacity | Base64-encoded sort values can be forged to probe outside an authorized filter set — an authorization-bypass vector, not a polish item | Make tamper-evidence a go/no-go gate; pull Security into review |
| Abandonment | No upstream SLA or security-response process | Named internal owner; treat as adopted source, not a dependency |
Key questions to close before a go decision. Each needs a named owner and a yes/no answer; the spike is not complete until all six are answered in writing.
laravel-json-api majors and PHP/Laravel versions do the composer constraints support, and is there CI proving it? (Eng lead)total/last deliberately omitted and documented, since front-end teams will assume they exist? (TPM + client lead)400? (Security + Eng)sort differs from the sort embedded in the cursor? (Eng lead)Program verdict. Low risk to evaluate, moderate risk to depend on. The defensible posture is a one-day time-boxed spike with a pre-agreed fork plan and a named owner — not a line item on the critical path, and not a composer.json entry that nobody owns. Define success metrics up front so the pilot has a stopping rule: p95 latency on deep pages, client-reported duplicate/skip rate, share of 400s attributable to stale cursors, and support-ticket volume on the paginated surface.
Scoping criteria. Pursue only where there is a measured deep-pagination problem — duplicate or skipped records under concurrent writes, or OFFSET cost on a large, high-churn table. Good candidates are high-volume, append-mostly, stably ordered collections: activity feeds, audit and event logs, notifications, transaction histories, message threads, stable-relevance search, sync/export jobs. Poor candidates are admin tables with user-selectable columns and sorting, anything requiring meta.page.total, anything requiring page numbers, and small read-mostly tables where offset is cheap and universally understood. Apply per endpoint; never fleet-wide.
Sequencing.
page[cursor]/page[size] supported; total absent by design; cursors are not bookmarkable and not stable indefinitely).Compatibility discipline. Pin both this package and the parent to explicit versions; never float. Keep the pagination abstraction at the resource layer so a swap to in-house code is invisible to clients. Add a scheduled CI job running the suite against the parent's next minor as an early drift warning — this is the cheapest available substitute for the upstream maintenance signal that does not exist.
Contract management. The client-facing change is real but narrow: new query parameters accepted, links present, total absent. Get that into the API changelog and the client-team channel before the flag flips, not after. If any consumer currently assumes meta.page.total, inventory it during the spike — a missing field that a front-end team expects is a production incident, not a documentation gap.
Where a TPM adds the most value here. Not in evaluating the code, which is a few hundred MIT-licensed lines, but in sequencing: index-before-ship, vendoring-before-adoption, flag-before-flip, and surface-by-surface before any global pagination standard.
Ownership and maintenance. The decision that actually matters is "will we own the source," not "is the feature desirable" (it is). Approvable framing: a named internal owner plus vendoring, never a bare dependency line. Budget for a recurring tax at parent-upgrade time — each laravel-json-api major or minor that touches the Page contract requires a compatibility review and possibly a patch to our fork. Put that on the release checklist rather than discovering it during an upgrade crunch.
Support model. There is no upstream SLA, security-response process, or release cadence to lean on. Our internal owner is first responder. That means a runbook covering the three predictable tickets: a client sending a stale cursor after a deploy, a client sending a malformed cursor and getting a 400 it doesn't handle, and a client that expected total. Write it during the pilot, while the context is fresh.
Scaling behavior. Keyset pagination is roughly O(log n) per page versus OFFSET's linear cost, which can defer read-replica or additional-index spend on deep list views — only if the composite index matching the exact sort tuple exists. Without it, this is slower than what we have. Note the limit honestly: cursor pagination does nothing for the cost of relation-heavy includes, which remain the dominant expense on many feed endpoints. Measure both before claiming a win.
Failure modes and their blast radius.
| Failure | Detection | Client impact | Owner |
|---|---|---|---|
| Forged cursor bypassing an authorized filter | Security review / negative tests | Data exposure — highest severity | Security + Eng |
| Sort mismatch between request and cursor | Characterization tests | Wrong or repeated rows | Eng lead (endpoint) |
| Stale cursor after deploy or backfill | 400 rate monitoring | Broken "load more" until client restarts | Eng lead + support |
| Missing composite index | Load test on deep pages | Latency regression vs. status quo | Eng lead + DBA |
NULL sort keys or driver collation differences |
Cross-driver test matrix | Silent skips/dupes, hard to reproduce | Eng lead |
Parent upgrade breaks Page contract |
CI against next minor | Endpoint-wide 500s | Internal owner |
Client assumes total exists |
Contract review, client inventory | Client-side errors, tickets | TPM |
Ramp-up. About one engineer-day to productive, lower with characterization tests in place. The knowledge that is genuinely hard to transfer is not the package's API but the per-endpoint index and sort contracts — document those in the resource definition, not in a wiki, or they will rot and the next engineer will add a sortable column without a matching index and quietly reintroduce the instability this was adopted to remove.
Program posture in one line. Worth adopting as vendored source under a named owner; not worth depending on as a package; and only worth doing at all if JSON:API is already committed — otherwise call cursorPaginate() directly and skip the whole conversation.
How can I help you explore Laravel packages today?