Paginating collections¶
This page shows you how to break a collection into pages: pick a strategy, fetch
exactly the slice the request asks for, and let the library emit the
links.{first,prev,next,last} and meta.page that let a client walk the rest.
Pagination is two collaborating pieces:
- a strategy — it reads the request's
page[…]parameters and produces aPagevalue object holding the items for that page plus the link/meta to emit; and - the
Pageitself — pagination state lives here, on the value object, never on a collection or a domain object. A collection you do not paginate carries no pagination concerns at all.
You wire one strategy as the default and override it per resource (or per
relation) where you need a different shape. The library never touches your data
layer, so applying the strategy — loading the right slice and counting the
total — happens in your collection handler. The two-method contract below is
designed precisely so that loop pushes down to a LIMIT/OFFSET (or an
array_slice) instead of loading everything.
Declaring a paginator¶
The simplest wiring is a server-wide default. The music catalog registers a
PagePaginator with a ten-per-page default in
bootstrap.php:
use haddowg\JsonApi\Pagination\PagePaginator;
$server = Server::make()
->withBaseUri('https://music.example')
// …
->withDefaultPaginator(PagePaginator::make()->withDefaultPerPage(10));
Every collection now paginates with page[number] / page[size] and a default
page size of ten, unless a resource or relation declares its own. A
Resource overrides the default by implementing pagination():
public function pagination(?\haddowg\JsonApi\Pagination\PaginatorInterface $serverDefault): ?\haddowg\JsonApi\Pagination\PaginatorInterface
{
return PagePaginator::make()->withDefaultPerPage(25);
}
pagination() receives the resolved server-default paginator and its return is
the single source of truth — used verbatim:
- return
$serverDefault(or don't overridepagination()— that's the base) to inherit the server default; - return a paginator to pin that strategy for this resource;
- return
nullto disable pagination — the collection is fetched whole andDataResponse::fromCollection()serves the entire list (withmeta.total, see Counting and totals).
(Pass null to withDefaultPaginator() and the server has no default, so a
non-overriding resource is unpaginated too.)
The two-method contract¶
Every strategy implements
PaginatorInterface. Two of its methods
do the count-based work, running at different points in your fetch loop:
public function window(JsonApiRequestInterface $request): WindowInterface;
public function paginate(JsonApiRequestInterface $request, iterable $items, int $totalItems): PageInterface;
window() runs first, before any items are materialised: it reads the
page[…] parameters and returns the slice your store must fetch, so you can push
that down to a query. paginate() runs last: it wraps the items you already
fetched for that window plus the separately-computed total of the whole filtered
collection. Pages never slice — paginate() trusts that $items is exactly the
window — so the two methods share one derivation and always agree, even on
garbage input. (The interface's third method,
paginateWithoutCount(), is the no-COUNT variant of
paginate().)
The InMemoryRepository
runs the canonical window → slice → count → paginate loop:
$window = $paginator->window($request);
$total = \count($rows);
$slice = $window instanceof OffsetWindow
? \array_slice($rows, $window->offset, $window->limit)
: $rows;
return $paginator->paginate($request, $slice, $total);
Replace \array_slice with LIMIT $window->limit OFFSET $window->offset and
\count with a COUNT(*) and the same loop pushes down to SQL. The handler picks
the strategy (resource → server default) and hands the resulting Page to
DataResponse::fromPage(), seen in
MusicCatalogHandler:
$paginator = $resource->pagination($server->defaultPaginator());
$result = $this->repository->fetchCollection(/* … */ $paginator);
if ($result instanceof PageInterface) {
return DataResponse::fromPage($result, $serializer);
}
return DataResponse::fromCollection($result, $serializer);
The fetch window¶
WindowInterface is the strategy-shaped
handoff between window() and your store. The three count-based strategies all
produce an OffsetWindow — public readonly
offset and limit, both normalised to >= 0 at construction:
final readonly class OffsetWindow implements WindowInterface
{
public function __construct(int $offset, int $limit)
{
$this->offset = \max(0, $offset);
$this->limit = \max(0, $limit);
}
}
Because the values are pre-normalised, your data layer hands them straight to
LIMIT/OFFSET (or array_slice) without re-validating. Garbage page[…] input
therefore yields a sane (possibly empty) window, never a 400 — ?page[number]=-5&page[size]=abc
clamps to a valid slice and still returns 200. WindowInterface is the open seam:
a data layer narrows on the concrete window type it knows how to execute — the three
count-based strategies all produce an OffsetWindow, while
cursor pagination produces a
CursorWindow (a limit plus the decoded
after/before boundaries) that a keyset-capable layer executes instead.
The shared window executor¶
The window → count → slice loop above is the same in every store, so the library
ships it as a storage-agnostic core seam:
WindowExecutor. It references only
core/PHP types and takes the store-specific work — materialize the whole filtered
set, count it, fetch a windowed page, probe one item past a page — as closures,
so a Doctrine layer passes LIMIT/OFFSET/COUNT push-down closures, an in-memory
layer passes array_slice/count closures, and both get the identical branch
logic:
- no window → the whole filtered set, no count;
- a counted page → count the pre-window total, fetch the window;
- a count-free page → no
COUNT; probelimit + 1and a surplus item proves a further page (see the count-free note below); - a
CursorWindow→ its own entry pointrunCursor()(keyset, count-free).
run() returns a CollectionResult —
the materialized items plus the pre-window total (non-null only on a counted page),
a windowed flag, and a hasMore flag for the count-free branch. The handler then
narrows on it to build the right Page. (A keyset fetch returns the
CursorCollectionResult subtype,
which additionally carries the minted boundary cursors.) The
InMemoryRepository in
the worked example runs the loop inline for clarity; a real provider hands the four
closures to WindowExecutor once and gets every branch for free.
The four strategies¶
All four strategies are final readonly, built with a make() named constructor
and refined with immutable with…() helpers. An absent or non-numeric page[…]
value falls back to the configured default (matching the request-side parsing rule
— it never throws).
| Strategy | Reads | Defaults (keys / values) |
|---|---|---|
PagePaginator |
page[number] / page[size] |
number/size, page 1, per-page 15, max-per-page 100 |
OffsetPaginator |
page[offset] / page[limit] |
offset/limit, offset 0, limit 15, max-per-page 100 |
FixedPagePaginator |
page[number] only |
number, page 1, fixed size 15 (server-set, never echoed) |
CursorPaginator |
page[size] / page[after] / page[before] |
size, default size 15, max-per-page 100 |
All four implement the same PaginatorInterface seam — window() returns the
fetch window a data layer pushes down. The first three are count-based: their
window is an OffsetWindow and paginate() builds a page from
the windowed items and a separately-computed total. The fourth,
CursorPaginator, is count-free: its window is a
CursorWindow and its render path is fromBoundaries() rather than paginate() —
it is covered separately under Cursor pagination below. The
three client-size-controlled strategies cap page[size]/page[limit] — see
Capping the page size.
PagePaginator — the baseline¶
page[number] / page[size]. Override the keys and defaults with
withPageKey(), withPerPageKey(), withDefaultPage() and withDefaultPerPage():
Each helper returns a new instance, so a configured paginator is shared safely. Its
window() derives offset = (number - 1) * size; its paginate() returns a
PageBasedPage carrying the full first/prev/next/last set.
OffsetPaginator — offset and limit¶
The same shape with row-offset semantics: page[offset] / page[limit], keyed and
defaulted with withOffsetKey(), withLimitKey(), withDefaultOffset() and
withDefaultLimit(). Its meta.page reports offset/limit rather than
currentPage/perPage.
FixedPagePaginator — server-fixed size¶
For endpoints where the server fixes the page size and the client only sends
page[number]:
The configured size (default 15) is used solely to compute the last page — it
is never echoed in the emitted links. Refine with withSize(), withPageKey()
and withDefaultPage(). It has no page-size cap because the client never controls
its size.
Count-free pages¶
A COUNT(*) over the whole filtered collection is often the most expensive part of
a paginated fetch, and some collections cannot be counted at all (a non-countable
related to-many — see countable relations). The three
count-based strategies therefore expose a count-free mode alongside paginate():
public function paginateWithoutCount(JsonApiRequestInterface $request, iterable $items, bool $hasMore): PageInterface;
paginateWithoutCount() builds the page without a total: it omits
meta.page.total and the last link, keeps self/first/prev, and derives
next from $hasMore rather than from the total. Your store learns $hasMore
without a COUNT by fetching one item past the window (limit + 1) — a surplus
row proves a further page follows. This is exactly the
WindowExecutor count-free branch, and the same
count-free shape cursor pagination is built on.
Count-free is the default. A paginator never asks to run the COUNT unless told
to: PaginatorInterface::wantsCount() is false until an author flips it. A handler
that honours the flag renders a plain GET /articles?page[size]=2 count-free — no
meta.page.total, no last link, next driven by hasMore, zero COUNT queries.
(This example's repository counts its in-memory slice eagerly, so its pages always
carry the total; a store where COUNT is expensive reads wantsCount() and calls
paginateWithoutCount() instead.) See Counting and totals
for how to turn counting on.
Counting and totals¶
Counting is opt-in, and a total — wherever it appears — is computed once and
rendered consistently. A paginator advertises whether it wants the COUNT via
wantsCount() (default false), which a host's handler reads to choose paginate()
vs paginateWithoutCount(). There are two levers to turn counting on:
withCount()on a count-based paginator (author-always): the paginator runs theCOUNTon every paged request, someta.page.totaland thelastlink are always present. No profile or param needed.
public function pagination(?PaginatorInterface $serverDefault): ?PaginatorInterface
{
return PagePaginator::make()->withDefaultPerPage(25)->withCount();
}
countable()on the resource (client-on-demand): the resource declares its primary collection countable, and a client requests the total per request with the reserved?withCount=_self_token (under the negotiated Countable profile). A?withCount=_self_against a resource that is notcountable()is a400.
// The resource opts in once …
(new ArticleResource())->countable();
// … then a client asks per request:
// GET /articles?page[size]=2&withCount=_self_
When a total is computed — for either reason — the same number is written to the
top-level meta.total (the universal cardinality slot) and, additionally, to
meta.page.total when the collection is paginated; never counted twice. The
cursor strategy is inherently count-free and takes neither lever.
No paginator ⇒ free total. If the resolved paginator is null (no server
default, none on the resource, or withoutPagination() on a relation), the collection
is fetched whole — so its size is already known and counting is free. In that case
meta.total is rendered unconditionally (even unrequested); there is no
meta.page.total (no pagination).
Capping the page size¶
The client controls page[size] (and page[limit]), so without a ceiling a
single request can ask for page[size]=1000000 and force your store to fetch a
million rows — a denial-of-service vector. The page-size strategies therefore
cap the resolved size: PagePaginator, OffsetPaginator and CursorPaginator
clamp an over-large request down to a maximum, the same clamp-don't-400 stance
as every other garbage page[…] value. The cap is on by default at 100, so
every store is protected without any configuration:
Tune it with withMaxPerPage() — the cap only clamps down, never raising a
smaller request, and the default-per-page is untouched as long as it sits at or
below the cap:
$paginator = PagePaginator::make()
->withDefaultPerPage(25)
->withMaxPerPage(50); // page[size]=1000 → 50; page[size]=10 → 10; no page → 25
The cap applies in both places that read the size, so they always agree: the
fetch window your store loads (window()->limit) and the
rendered meta.page size. An over-large page[size] thus returns the capped
number of items with a 200, and meta.page.perPage reports the cap — never the
abusive number.
Pass 0 to disable the cap (unlimited):
The music catalog caps its default
paginator at 50 to witness the clamp; page[size]=1000000 there returns at most
50 items with meta.page.perPage: 50.
Cursor pagination¶
Cursor (keyset) pagination has a different shape — no total, opaque boundary
tokens — but it lives under the same seam as the count-based strategies:
CursorPaginator implements
PaginatorInterface, so it is a drop-in default for a resource, a relation or the
server. Its window() returns a CursorWindow
(the resolved size plus the decoded page[after] / page[before] boundaries) that
a keyset-capable data layer executes exactly as it narrows on the
OffsetWindow — see the fetch window.
A cursor page has no total count by design (computing one would defeat the
purpose of cursors), so it is inherently count-free and emits no last link.
Its prev/next boundaries are the cursors of the returned items, which only the
executing provider can mint (it owns the row → boundary-value reader). So the
cursor render path is not the count-based paginate() — it is the dedicated
fromBoundaries(), which takes the minted boundary cursors and the
has-next / has-previous flags directly rather than a total:
public function fromBoundaries(
JsonApiRequestInterface $request,
iterable $items,
int|string $cursorBefore, // cursor of the first returned item (for `prev`)
int|string $cursorAfter, // cursor of the last returned item (for `next`)
bool $hasNext,
bool $hasPrevious,
int|string|null $from = null, // id of the first row (for `meta.page.from`)
int|string|null $to = null, // id of the last row (for `meta.page.to`)
): CursorBasedPage;
use haddowg\JsonApi\Pagination\CursorPaginator;
$window = $paginator->window($request); // CursorWindow: size + boundaries
// … the provider runs the keyset fetch for $window, returning the page items
// plus the boundary cursors it minted for the first and last rows …
$page = CursorPaginator::make()
->withDefaultSize(20)
->fromBoundaries($request, $items, $firstCursor, $lastCursor, $hasNext, $hasPrevious);
return DataResponse::fromPage($page, $server->serializerFor('tracks'));
The two interface methods — paginate($request, $items, $totalItems) and
paginateWithoutCount($request, $items, $hasMore) — are present only for
PaginatorInterface conformance: a cursor strategy never derives a total, so the
$totalItems argument is ignored and both build a page without boundary cursors
(no prev/next). Use fromBoundaries() for a real keyset page.
CursorPaginator reads page[size] (rekey with withSizeKey()) and emits
page[after] / page[before] cursors; the size is capped
at 100 by default like the other client-controlled strategies. The produced
CursorBasedPage carries the published
cursor-pagination profile (CursorPaginationProfile, URI
https://jsonapi.org/profiles/ethanresnick/cursor-pagination/), so a
cursor-paginated response advertises the profile on its Content-Type and in
jsonapi.profile — provided the server has registered it with
withProfile(new CursorPaginationProfile()). The catalog registers it in
bootstrap.php for exactly this.
Its meta.page carries perPage and a hasMore flag (plus from/to ids when
the page is non-empty) — never a total, since there is none:
The keyset execution toolkit¶
A data layer executing a CursorWindow should not reimplement the keyset
mechanics — the core ships them under Collection\Keyset, one shared source for
every adapter so the SQL push-down and any in-memory execution cannot drift:
KeysetResolver— resolves the request's active sort into the ordered keyset columns the page walks (validated exactly like the plain sort path, the primary key appended as the final total-order column) and enforces cursor staleness:assertFresh()rejects a boundary minted under different columns or flipped directions.KeysetColumn— one resolved(column, direction)level of that order.InMemoryKeyset— the PHP execution of the order: the forced NULL=largest comparator (sort()) and the lexicographic strictly-after predicate (after()/isAfter()). It is both a ready-made in-memory executor and the ground truth a SQL push-down's forcedORDER BY/keysetWHEREmust match byte-for-byte.CursorTokenMinter— mints the opaque boundary tokens off the sliced page rows (JSON-safe value coercion viacoerce(), the forward/backwardhasMore/hasPreviousrules) and assembles theCursorCollectionResult.
The composition recipe — resolve columns, check staleness, fetch strictly-after
rows over-fetched by one, slice, re-orient a backward page, mint — is witnessed
end-to-end by KeysetCursorRoundTripTest;
an adapter supplies only its store's fetch (a pushed-down WHERE/ORDER BY, or
InMemoryKeyset as-is) and a row → keyset-value reader.
The Page value object¶
PageInterface is generic (PageInterface<T>)
and iterable — it extends \IteratorAggregate, re-keying items to integer
indices — so DataResponse::fromPage($page, …) walks the items without unwrapping.
It exposes three methods the response layer calls:
public function linkSet(string $uri, string $queryString): array; // array<string, Link|null>
public function pageMeta(): array; // array<string, mixed>
public function profile(): ?ProfileInterface;
linkSet() returns the pagination links keyed by relation
(self/first/prev/next/last); a null value means that relation is
omitted for this page (e.g. prev on the first page, or last for cursor pages).
Links are absolute and query-string-preserving: the strategy's page[…]
parameters are merged over the request's current query string, so filter, sort
and sparse fieldsets survive across pages — GET /tracks?page[size]=2&sort=trackNumber
emits a next link that still carries sort=trackNumber. pageMeta() returns the
meta.page contents (each strategy's shape differs — see the table below).
profile() is the third method: it returns the profile a page activates (the
cursor page returns its profile; the count-based pages return null).
The concrete pages — PageBasedPage, OffsetBasedPage, FixedPagePage,
CursorBasedPage — are the subtypes the strategies return. You rarely construct
them directly; let the strategy do it.
Emitted page shapes¶
The four pages side by side — which links they emit and what meta.page carries:
| Page | Links emitted | meta.page keys |
|---|---|---|
PageBasedPage |
self, first, prev, next, last |
currentPage, perPage, from, to, total, lastPage |
OffsetBasedPage |
self, first, prev, next, last |
offset, limit, from, to, total |
FixedPagePage |
self, first, prev, next, last |
currentPage, total, lastPage (no perPage — size is server-fixed) |
CursorBasedPage |
first, prev, next (no self or last by design) |
perPage, hasMore (+ from, to on a non-empty page; never total) |
Two defensive behaviours apply to the count-based pages:
- An empty or degenerate collection suppresses the whole link set. When
total <= 0or the effective page size / limit is<= 0,linkSet()returns every relation asnull— nofirst/lastpointing at nothing. self,prevandnextarenullat the boundaries.previs omitted on the first page,nexton the last, andselfwhen the requested page falls outside the valid range. The cursor page additionally omitslastalways (it has no total to locate a last page).
So GET /tracks?page[number]=1&page[size]=2 over three tracks emits first,
next and last but no prev; page[number]=2 (the last page) emits prev but
no next, and its meta.page reads currentPage: 2, perPage: 2, total: 3,
lastPage: 2.
Per-relation pagination¶
A to-many relationship's related-collection endpoint (GET /{type}/{id}/{rel})
paginates independently of the primary collection. Declare a per-relation strategy
with paginate() on the relation builder —
AlbumResource windows
an album's tracks two-per-page:
PlaylistResource
does the same for its tracks relation. A to-one relation has no collection and
ignores paginate(). Opt a relation out of pagination entirely with
withoutPagination() — its related collection is then fetched whole (and renders
meta.total unconditionally, see Counting and totals).
The effective strategy for a related collection resolves through a three-step
fallback chain — relation → related resource → server default — which the
handler threads through pagination(): the resolved fallback is passed in, and the
relation returns its own paginator over it (or null, via withoutPagination()):
$fallback = $relatedResource?->pagination($server->defaultPaginator())
?? $server->defaultPaginator();
$paginator = $relation->pagination($fallback);
The related slice then renders through
RelatedResponse::fromPage() (the primary-collection twin is
DataResponse::fromPage()). RelatedResponse::fromPage() scopes the pagination
links to the related URL, so next/prev on GET /albums/1/tracks point back
at the related endpoint, not at /tracks. A polymorphic to-many carries no shared
filter/sort vocabulary, so on it filter/sort are a 400 and only page
windows the mixed members — see related endpoints.
Offering a menu of strategies¶
Sometimes one collection should support more than one pagination strategy and let
the client pick per request — page-number for a human paging a UI, cursor for a
stable deep-scroll export. Wrap the strategies you want to offer in a
MultiPaginator — itself a
PaginatorInterface, so it drops straight into pagination(), paginate() or
withDefaultPaginator():
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 author declares the menu, so a client can only ever select a strategy you
offered — never invent one. Each strategy names itself with a kind() — the
built-ins are page, offset, cursor and fixed; give a second instance of the
same strategy a distinct name with withKind('…') (two children sharing a kind is a
wiring error). ->default('cursor') is the strategy used when a request carries no
pagination at all (the first-declared child if you omit it).
How the client selects¶
Selection is discriminator-first but not discriminator-only:
page[kind]=<kind>selects that strategy outright —GET /tracks?page[kind]=cursor&page[size]=20. An unknown kind is a400whosedetaillists the kinds on offer.- a strategy-unique
page[…]key selects its owner without akind: apage[after]/page[before]implies cursor, apage[offset]/page[limit]implies offset. This keeps the cursor-pagination profile's bare params working unchanged. - a shared key (
page[size],page[number]) is ambiguous across strategies, so it does not select on its own — the request falls back to your declareddefault(). To pin a non-default strategy while sending only a shared key, addpage[kind]. - an absent
pageuses thedefault().
Everything else about pagination is unchanged: the handler resolves the wrapper to the one concrete strategy for the request up front, then runs the ordinary window → slice → count → paginate loop. Because the resolved strategy is a plain paginator, cursor selection still activates the cursor-pagination profile on exactly the responses it produces.
In OpenAPI¶
The whole page[…] family always projects as one page query parameter
(style: deepObject), so page[number]=2&page[size]=10 is the unchanged wire form.
For a single strategy its schema is a plain object of that strategy's keys; for a menu
it is a oneOf of the children's page objects — each carrying an optional
kind const and additionalProperties: false, with a discriminator on kind. The
schema therefore encodes the selection rule itself: a unique-key object matches
exactly one branch, while a shared-key-only object matches several and is invalid
until page[kind] disambiguates it.
Custom strategies¶
Supply your own count-based strategy by implementing PaginatorInterface and
returning whatever PageInterface subtype suits — a custom meta.page shape, a
keyset window, a different link policy. Register it per resource (pagination()),
per relation (paginate()) or server-wide (withDefaultPaginator()); the
window → slice → count → paginate loop is unchanged. A custom strategy names itself
via kind() and self-describes its page[…] object schema via
describePageSchema(), so it composes into a MultiPaginator menu and projects its
real parameters with no central switch.
Next / See also¶
- Responses —
DataResponse::fromPage()/fromCollection()andRelatedResponse::fromPage(). - Profiles — the cursor-pagination profile and how a page activates it.
- Filters and Sorts — the criteria preserved across pagination links.
- Resource classes — declaring a per-type default
pagination(). - Server — the server-wide default paginator.
- Related endpoints — where per-relation pagination applies.