ryckakas / cursor-walk
The fetching half of cursor pagination for PHP: walk paginated upstream APIs lazily, with cursor round-tripping and Relay connection output.
Requires
- php: >=8.2
Requires (Dev)
- ext-simplexml: *
- friendsofphp/php-cs-fixer: 3.95.18
- infection/infection: 0.32.6
- phpstan/phpstan: 2.2.7
- phpunit/phpunit: 11.5.56
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
The fetching half of cursor pagination for PHP.
You implement one method, fetchPage(?string $cursor): Page, that turns one upstream response into
one page. cursor-walk owns the loop around it: threading the cursor, fetching each page only when
iteration reaches it, resuming from a checkpoint, stopping runaway upstreams, and cutting exact-N
slices for a Relay first. It works against a REST API, a GraphQL API, a DynamoDB scan, or
anything else that says "here is a page, here is the cursor for the next one".
Why this one
- Lazy by construction. One upstream call per page, made when iteration crosses into it. A client asking for 25 items from 100-item pages costs one call, not the whole dataset in memory.
- Guarded by default. A repeated cursor throws. An upstream that hands out fresh cursors forever hits a page budget, and the exception carries the cursor to resume from.
- Resumable. Every
endCursoris a checkpoint, so a batch job that dies halfway picks up at the page it was on. - Exact slices.
slice()assembles exactlyfirstitems across as many fetches as it takes, even when the end lands in the middle of an upstream page. - Relay output with no GraphQL dependency.
ConnectionFormatterreturns a plainedges/pageInfoarray that a resolver can return as is. - Any cursor, any transport. Opaque tokens, page numbers and row offsets all work, because the
engine never looks inside a cursor. The library makes no requests of its own, and
requireisphpalone.
Install
composer require ryckakas/cursor-walk
PHP 8.2 or newer. No runtime dependencies.
Use it
Implement PaginatedFetcher, the only integration point: one upstream response in, one Page out.
use CursorWalk\Page; use CursorWalk\PaginatedFetcher; use CursorWalk\Paginator; /** @implements PaginatedFetcher<array{id: int, name: string}> */ final class WidgetFetcher implements PaginatedFetcher { public function __construct(private readonly WidgetApi $api) // your HTTP client, SDK, anything { } public function fetchPage(?string $cursor): Page { $response = $this->api->listWidgets(limit: 100, after: $cursor); return new Page($response['data'], $response['next_cursor'] ?? null, $response['has_more']); } } $paginator = new Paginator(); foreach ($paginator->items(new WidgetFetcher($api)) as $widget) { // Page 2 is fetched when the loop reaches item 101, not before. }
The same fetcher over a PSR-18 client, validating the envelope
use CursorWalk\Exception\MalformedPageException; use CursorWalk\Page; use CursorWalk\PaginatedFetcher; use Psr\Http\Client\ClientInterface; use Psr\Http\Message\RequestFactoryInterface; /** @implements PaginatedFetcher<array{id: int, name: string}> */ final class WidgetFetcher implements PaginatedFetcher { public function __construct( private readonly ClientInterface $http, private readonly RequestFactoryInterface $requests, ) { } public function fetchPage(?string $cursor): Page { $url = 'https://api.example.com/widgets?limit=100' . ($cursor === null ? '' : '&after=' . urlencode($cursor)); $body = (string) $this->http->sendRequest($this->requests->createRequest('GET', $url))->getBody(); $data = json_decode($body, true); if (!is_array($data) || !isset($data['data']) || !is_array($data['data'])) { throw MalformedPageException::invalidEnvelope('missing "data" key', $cursor, $body); } return new Page( items: array_values($data['data']), endCursor: $data['next_cursor'] ?? null, hasNextPage: (bool) ($data['has_more'] ?? false), totalCount: $data['total'] ?? null, ); } }
Snippets omit declare(strict_types=1);. The library declares it in every file, and your
fetchers should too.
Paginator is stateless: inject one and reuse it. It has three methods.
| Method | Returns | Use it for |
|---|---|---|
items($fetcher, ?string $pageCursor = null, int $skip = 0) |
Generator<int, T> |
Streaming every item. |
pages($fetcher, ?string $startCursor = null) |
Generator<int, Page<T>> |
Batch jobs: one transaction, retry and checkpoint per page. |
slice($fetcher, int $first, ?string $pageCursor = null, int $skip = 0) |
Page<T> |
Exactly N items, such as a Relay first. |
The page budget
new Paginator() stops after 10,000 pages and throws PageBudgetExceededException, whose
getLastCursor() resumes the walk. Loop detection catches a cursor that repeats, but not an
upstream that returns a fresh cursor with hasNextPage: true forever. The budget does. Size it to
the caller, not the dataset:
$interactive = new Paginator(maxPages: 50); // an API request: fail fast $batch = new Paginator(maxPages: 100_000); // a nightly sync: still bounded $unbounded = new Paginator(maxPages: null); // no limit, and the risk is yours
Why the budget is on by default, and what null costs, is in
the design notes.
When a walk fails
| Exception | Meaning | Typical response |
|---|---|---|
MalformedPageException |
The upstream response is not a usable page. | Log getCursor() and getRawContext(); retry or fail the request. |
PaginationLoopException |
A cursor repeated, so the walk would never end. | A bug upstream or in the fetcher. Do not retry. |
PageBudgetExceededException |
Your maxPages was reached. |
Resume from getLastCursor(), or raise the budget. |
All three extend CursorWalk\Exception\CursorWalkException, a \RuntimeException. Page itself
throws MalformedPageException for the one combination it refuses: hasNextPage: true with no
endCursor. Anything stricter belongs in your fetcher; see
rejecting malformed pages.
Recipes
- A GraphQL resolver returning a Relay connection:
slice()andConnectionFormatter, with edge cursors that round-trip asafter. - Batch jobs that checkpoint per page:
pages()as the transaction and retry boundary. - Resuming a walk: from a checkpoint, a client's cursor, or an exhausted page budget.
- Page-numbered and offset upstreams:
?page=2and?offset=100APIs throughPageNumberFetcherandOffsetFetcher. - Driving the walk yourself:
Walk, for Temporal workflows and event loops that cannot let the library callfetchPage(). - Rejecting malformed pages: stricter upstream policy in the fetcher, and catching each failure separately.
Important
The default Relay edge cursor names a position, not an item, so it can repeat or skip rows if the upstream changes between requests. Read cursor stability before storing one.
Documentation
- Recipes: the patterns above, in full
- Design notes: the guards, cursor stability, what it deliberately does not do, and how it fits with webonyx/graphql-php, graphql-relay-php and relay-pagination
- Roadmap: what is likely to come next, and why
- Changelog: what changed in each release
Development
Commands and gates
composer check # code style, static analysis, complexity, tests, offline example composer check:ci # everything CI runs, adding coverage, mutation and the public API check
check:ci needs xdebug or pcov. The complexity gate is
bonsai-lint, a dev dependency like the rest. composer bc
fails on any break of the public API since the latest release, using
Roave BackwardCompatibilityCheck; it needs
PHP 8.4+ and composer install -d tools/bc-check once. CI also audits the workflows with
zizmor. AGENTS.md has the conventions.
License
MIT. See LICENSE.
