Pagination¶
Collections paginate by default. The strategy is core's — page-number, offset, or cursor (keyset) — and the package pushes each down through the Eloquent provider. This page covers the Laravel affordances; the paginator DSL and query-param shapes are core's (see core pagination).
The default page paginator¶
Out of the box a collection uses the built-in page-number paginator, bounded by
jsonapi.pagination.max_per_page (a page-size DoS bound — the request stays 200, an
oversized page[size] is clamped, not rejected):
Set max_per_page to 0 in configuration to disable the built-in
default entirely (collections then render unpaginated unless a resource declares its own
paginator).
Choosing a paginator per resource¶
A resource picks its paginator by overriding pagination() (or, for a relation, ->paginate(...)).
Core ships:
| Paginator | Query shape | Notes |
|---|---|---|
PagePaginator |
page[number] / page[size] |
page-number with a total + last page |
OffsetPaginator |
page[offset] / page[limit] |
offset/limit |
CursorPaginator |
page[after] / page[before] / page[size] |
keyset — stable for large/deep/live collections |
use haddowg\JsonApi\Pagination\PagePaginator;
public function pagination(?PaginatorInterface $serverDefault): PaginatorInterface
{
return PagePaginator::make()->withDefaultPerPage(20);
}
Cursor (keyset) pagination¶
CursorPaginator resolves a keyset window; the provider runs the keyset push-down. It always
appends a deterministic id tie-breaker to the sort, so a page boundary is stable even when the
declared ?sort column is non-unique (and even for a bare request with no ?sort — a
primary-key-only keyset). The example's cursorWidgets type exercises this against both
providers:
use haddowg\JsonApi\Pagination\CursorPaginator;
public function pagination(?PaginatorInterface $serverDefault): PaginatorInterface
{
return CursorPaginator::make()->withDefaultPerPage(2);
}
Offering a menu of strategies (page[kind])¶
A resource can offer several pagination strategies and let the client pick one per
request — page-number for a browsing UI, cursor for a stable deep-scroll export. Return a
MultiPaginator
from pagination() (or a relation's paginate()); it is itself a PaginatorInterface, so
nothing else changes:
use haddowg\JsonApi\Pagination\CursorPaginator;
use haddowg\JsonApi\Pagination\MultiPaginator;
use haddowg\JsonApi\Pagination\PagePaginator;
public function pagination(?PaginatorInterface $serverDefault): PaginatorInterface
{
return MultiPaginator::make(
PagePaginator::make()->withDefaultPerPage(20),
CursorPaginator::make(),
)->default('cursor');
}
The client selects with page[kind]=page (or =cursor); a strategy-unique key selects
without a kind (page[after]/page[before] → cursor, page[offset]/page[limit] → offset),
a shared key (page[size]/page[number]) needs page[kind], and an absent page uses
the declared default(). An unknown kind is a 400 PAGINATION_KIND_UNKNOWN naming page[kind].
The handler resolves the wrapper to its concrete strategy once, up front, so the count-based and
cursor render paths behave exactly as for a single strategy; the OpenAPI document projects the
menu as one page deepObject whose schema is a oneOf discriminated by kind. A cursor strategy
works on an included relation too: an include carries no cursor token, so a cursor-resolved
include renders the first cursor page per parent (next carries the minted page[after], no
prev/last), minted through the same per-parent keyset fetch the related endpoint runs — and the
document advertises the cursor-pagination profile even when the primary collection is page-based.
Relationship pagination¶
A related to-many paginates independently — declare it on the relation. Per-parent windowing uses the SQL push-down described in eloquent:
The default resolves relation → related resource → server default. The example's albums.tracks
and playlists.tracks/orderedTracks all page two-per-page.
A relation may declare a CursorPaginator too — the related endpoint then serves keyset pages
scoped to the parent, with the cursor links built on the related URL:
This covers a pivot-carrying belongsToMany as well: the keyset composes on top of the pivot
join (the sort columns always qualify off the related table), and each member's stored pivot
still renders as meta.pivot on the cursor page:
BelongsToMany::make('widgets', 'cursorWidgets')
->fields(Integer::make('position'))
->paginate(CursorPaginator::make()->withDefaultSize(2));
Cursor pages on the relationship (linkage) endpoint¶
A queried relationship GET (GET /{type}/{id}/relationships/{rel} with
?page/?sort/?filter) over a cursor-paginated relation windows the identifier linkage to
a keyset page: the document's pagination links carry the real page[after]/page[before]
cursors (never a last), while the body stays links-only — identifier data members and no
meta.page (core ADR 0124). A bare (unqueried) relationship GET still renders the whole
association. A queried pivot relationship GET remains a 400 — query parameters on a
relationship endpoint are rejected, not silently ignored.
Advertising the cursor-pagination profile¶
A CursorBasedPage carries the published
cursor-pagination profile,
but core only advertises a page profile the server has registered. Register it via the
jsonapi.profiles config (class-strings, resolved through the container) and every cursor
page — primary, related, and windowed linkage — advertises the profile URI in
jsonapi.profile and the Content-Type profile media-type parameter:
Pagination links and meta¶
The paginator emits the standard links.first/prev/next/last (as the strategy allows —
a cursor emits prev/next only) and meta.page totals. These are core's rendering; nothing
Laravel-specific to configure.