Pagination
Cursor-based keyset pagination — O(1) performance at any depth, how PageResult works, and how to consume paginated results.
Pagination
Vortos uses keyset (cursor-based) pagination exclusively. Offset pagination (LIMIT x OFFSET y) is never used — it degrades linearly as datasets grow and produces inconsistent results when records are inserted or deleted during pagination.
Why Keyset Over Offset
| Offset Pagination | Keyset Pagination | |
|---|---|---|
| Performance at page 1 | O(1) | O(1) |
| Performance at page 1000 | O(1000) — scans 1000×limit rows | O(1) — jumps directly to cursor |
| Consistent results | No — new inserts shift pages | Yes — cursor is stable |
| Works with sorted results | Yes | Yes |
| SQL/MongoDB support | Universal | Requires indexed sort field |
PageResult
All paginated queries return PageResult:
final readonly class PageResult
{
public function __construct(
public array $items, // documents for this page
public ?string $nextCursor, // opaque cursor — pass to next call
public bool $hasMore, // whether more pages exist
public ?int $total = null, // optional total count (expensive)
) {}
}nextCursor is an opaque base64-encoded string. Pass it back verbatim — do not parse, decode, or modify it.
Using findPage()
// First page
$page = $userReadRepository->findPage(
criteria: ['status' => 'active'],
limit: 20,
sort: ['createdAt' => 'desc'],
);
foreach ($page->items as $user) {
// process user
}
// Check if more pages exist
if ($page->hasMore) {
// Second page — pass cursor from first page
$nextPage = $userReadRepository->findPage(
criteria: ['status' => 'active'],
limit: 20,
cursor: $page->nextCursor,
sort: ['createdAt' => 'desc'],
);
}Iterating All Pages
function iterateAllUsers(UserReadRepository $repo, array $criteria): \Generator
{
$cursor = null;
do {
$page = $repo->findPage(
criteria: $criteria,
limit: 100,
cursor: $cursor,
);
foreach ($page->items as $item) {
yield $item;
}
$cursor = $page->nextCursor;
} while ($page->hasMore);
}
foreach (iterateAllUsers($repo, ['status' => 'active']) as $user) {
// process each user
}API Response Pattern
final class ListUsersController
{
public function __invoke(Request $request): JsonResponse
{
$page = $this->userReadRepository->findPage(
criteria: ['status' => 'active'],
limit: (int) $request->query->get('limit', 20),
cursor: $request->query->get('cursor'),
sort: ['createdAt' => 'desc'],
);
return new JsonResponse([
'data' => $page->items,
'has_more' => $page->hasMore,
'next_cursor' => $page->nextCursor,
]);
}
}The client passes ?cursor=<nextCursor> on subsequent requests to load the next page.
How Cursor Encoding Works (Internal)
The cursor contains the sort field values of the last item on the current page, plus _id as a tiebreaker. It is HMAC-signed to prevent tampering:
Last item on page: { _id: 'user-5', createdAt: '2026-04-27T10:00:00Z' }
Sort: ['createdAt' => 'desc']
Cursor payload: {"createdAt":"2026-04-27T10:00:00Z","_id":"user-5"}
Encoded cursor: base64(payload).hmac_sha256(payload, VORTOS_CURSOR_SECRET)On the next request, the cursor signature is verified before decoding. An invalid or tampered cursor is rejected with an exception. The decoded values are then converted to a MongoDB $gt filter (ascending sort) or $lt filter (descending sort) for each sort field. Only fields present in the current sort are allowed in the cursor — extra keys are rejected.
You never need to understand this — just pass nextCursor back verbatim. The cursor format is opaque and not guaranteed to be stable across framework versions.
InMemory Pagination
InMemoryReadRepository implements cursor pagination using integer offsets (simpler than field-value keyset, but correct for testing):
$repo = new InMemoryUserReadRepository();
// Seed test data
for ($i = 1; $i <= 25; $i++) {
$repo->seed("user-{$i}", ['_id' => "user-{$i}", 'name' => "User {$i}"]);
}
// First page
$page1 = $repo->findPage(criteria: [], limit: 10);
$this->assertCount(10, $page1->items);
$this->assertTrue($page1->hasMore);
$this->assertNotNull($page1->nextCursor);
// Second page
$page2 = $repo->findPage(criteria: [], limit: 10, cursor: $page1->nextCursor);
$this->assertCount(10, $page2->items);
// Third page (last)
$page3 = $repo->findPage(criteria: [], limit: 10, cursor: $page2->nextCursor);
$this->assertCount(5, $page3->items);
$this->assertFalse($page3->hasMore);
$this->assertNull($page3->nextCursor);InMemory Cursor Format Differs
InMemoryReadRepository encodes the cursor as a base64 integer offset. MongoStore encodes it as a base64 JSON object with sort field values. The formats are not interchangeable — do not mix cursors between implementations.
In practice this does not matter because you never parse cursors — you only pass them back verbatim.
Sort Field Index
For pagination to perform correctly in MongoDB, ensure your sort fields are indexed. The last field in a compound sort should include _id as a tiebreaker:
protected function indexes(): array
{
return [
// Pagination index: sort by createdAt descending, _id as tiebreaker
['key' => ['createdAt' => -1, '_id' => -1], 'options' => []],
// Filtered pagination: filter by status, sort by createdAt
['key' => ['status' => 1, 'createdAt' => -1, '_id' => -1], 'options' => []],
];
}Without a supporting index, MongoDB scans the entire collection on every paginated query — defeating the purpose of keyset pagination.