Skip to content

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 a Page value object holding the items for that page plus the link/meta to emit; and
  • the Page itself — 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 override pagination() — that's the base) to inherit the server default;
  • return a paginator to pin that strategy for this resource;
  • return null to disable pagination — the collection is fetched whole and DataResponse::fromCollection() serves the entire list (with meta.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; probe limit + 1 and a surplus item proves a further page (see the count-free note below);
  • a CursorWindow → its own entry point runCursor() (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():

$paginator = PagePaginator::make()
    ->withDefaultPerPage(20)
    ->withPerPageKey('size');

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]:

$paginator = FixedPagePaginator::make(50); // 50-per-page, fixed

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 the COUNT on every paged request, so meta.page.total and the last link 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 not countable() is a 400.
// 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:

// page[size]=1000000 → 100 items, 200 OK; meta.page.perPage reads 100.
PagePaginator::make();

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):

PagePaginator::make()->withMaxPerPage(0); // honours any page[size]

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.profileprovided 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:

{ "page": { "perPage": 20, "from": "4", "to": "23", "hasMore": true } }

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 forced ORDER BY/keyset WHERE must match byte-for-byte.
  • CursorTokenMinter — mints the opaque boundary tokens off the sliced page rows (JSON-safe value coercion via coerce(), the forward/backward hasMore/hasPrevious rules) and assembles the CursorCollectionResult.

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 <= 0 or the effective page size / limit is <= 0, linkSet() returns every relation as null — no first/last pointing at nothing.
  • self, prev and next are null at the boundaries. prev is omitted on the first page, next on the last, and self when the requested page falls outside the valid range. The cursor page additionally omits last always (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:

HasMany::make('tracks', 'tracks')
    ->paginate(PagePaginator::make()->withDefaultPerPage(2)),

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 a 400 whose detail lists the kinds on offer.
  • a strategy-unique page[…] key selects its owner without a kind: a page[after]/page[before] implies cursor, a page[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 declared default(). To pin a non-default strategy while sending only a shared key, add page[kind].
  • an absent page uses the default().

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

  • ResponsesDataResponse::fromPage() / fromCollection() and RelatedResponse::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.