Cursor pagination
Cursor pagination traverses a result set forward and backward without
OFFSET or a count query. The builder reads ?cursor=...
automatically and returns a CursorPaginationInterface result.
Doctrine quick start
1 2 3 4 5 6
// src/Controller/EventController.php
$events = $paginator
->cursor($repository->createQueryBuilder('event'))
->orderBy(['occurredAt', 'id'], 'DESC')
->perPage(25)
->paginate();
1 2 3 4 5 6
{# templates/event/index.html.twig #}
{% for event in events %}
<article>{{ event.occurredAt|date('c') }} - {{ event.label }}</article>
{% endfor %}
{{ ux_pagination(events) }}
For Doctrine ORM, the entity identifier is appended automatically when it is not already present. Including it explicitly documents the effective order and makes the required database index obvious. Doctrine DBAL has no entity metadata, so its complete stable order must be explicit.
orderBy() is required for the built-in array, Doctrine ORM and
Doctrine DBAL adapters: UX Pagination owns their effective order. It is
optional for a custom remote adapter that owns an opaque connection
order; that adapter returns a stable CursorOrder::byIdentity()
instead. See Custom data-source adapters and the worked GitHub GraphQL
pagination mapping in Integrations.
Stable ordering
The ordered fields must form a total deterministic order. A timestamp alone is usually insufficient because several rows can share it. Add a unique tie-breaker:
1 2
// Correct: equal timestamps are ordered by id.
->orderBy(['createdAt', 'id'], 'DESC')
The direction passed to orderBy() applies to every field. Mixed
directions such as createdAt DESC, id ASC are not supported.
Cursor navigation resists the offset shifts caused by inserts and deletions, as long as the ordered values of already-visible rows remain stable. It is not a database snapshot. Changing an ordered value, or inserting a row behind the current boundary, can move that row outside the remaining traversal.
Use snapshot isolation or an immutable feed key when the product requires a fixed point-in-time view.
PHP cursor values implementing DateTimeInterface are normalized to
UTC before they are compared or encoded. Equivalent instants keep the
same order even when the objects use different timezone offsets. Scalar
DBAL values are compared as returned by the driver, so use a canonical
database representation for ordered date fields.
Cursor pagination owns Doctrine's ORDER BY. Do not order the source
QueryBuilder first:
1 2 3 4 5 6 7
// src/Repository/EventRepository.php
$query = $this->createQueryBuilder('event'); // no orderBy()
$events = $paginator
->cursor($query)
->orderBy(['createdAt', 'id'], 'DESC')
->paginate();
Tokens and context
Tokens are versioned and signed with the configured cursor secret. Each token is bound to:
- the effective order fingerprint;
- the stable source context returned by the selected adapter;
- the configured cursor secret.
The built-in adapters derive the source context differently:
- Doctrine ORM includes the root entity, DQL and normalized parameters;
- Doctrine DBAL includes the SQL, parameter types and normalized values;
- the array adapter requires an explicit application context;
- a custom adapter defines the context for its own logical source.
An adapter may also include the value passed to context(). Use it for
application boundaries that are not already represented by the source.
The cursor secret is optional and falls back to kernel.secret.
Configure a dedicated value only when cursor URLs need an independent
rotation policy:
1 2 3 4
# config/packages/ux_pagination.yaml
ux_pagination:
cursor:
secret: '%env(UX_PAGINATION_CURSOR_SECRET)%'
The signing service is lazy: numbered pagination never reads or
initializes the cursor secret. If neither cursor.secret nor a
non-empty kernel.secret is available, the first cursor signing or
verification operation fails explicitly.
Bind a cursor to a tenant or another business boundary:
1 2 3 4 5 6
// src/Controller/InvoiceController.php
$invoices = $paginator
->cursor($repository->queryForTenant($tenant))
->orderBy(['issuedAt', 'id'], 'DESC')
->context('tenant:'.$tenant->getId())
->paginate();
Pass an explicit context() for implicit boundaries not represented
in the query and its parameters: active Doctrine filters, the current
shard, tenant state or a user-specific scope.
Tokens are authenticated, not encrypted. Their boundary values can be decoded by clients. The source context is stored as a hash, not as plain text, but low-entropy values can still be guessed. Treat tokens as opaque transport values and do not rely on them to conceal sensitive data.
The built-in array adapter requires an explicit, stable context:
1 2 3 4 5 6
$events = $paginator
->cursor($events)
->orderBy('id', 'ASC')
->perPage(25)
->context('event-feed')
->paginate();
Keep that context identical while the source grows or changes. It identifies the application feed. Do not derive it from the current items: every insertion or deletion would then invalidate existing cursors.
A custom adapter decides whether callers must provide context(). Its
getCursorContext() method must always return the same value for the
same logical source, regardless of the current page.
Navigation and APIs
The result exposes the items, the traversal state and generated URLs:
1 2 3 4 5 6 7 8
$events->getItems();
$events->hasNext();
$events->hasPrevious();
$events->getNextCursor();
$events->getPreviousCursor();
$events->getNextUrl();
$events->getPreviousUrl();
$events->getLinks(); // ['prev' => ?string, 'next' => ?string]
The JSON representation contains items, per_page, cursor,
next_cursor, previous_cursor, has_next, has_previous and
the bundle-generated links.prev / links.next URLs. Clients
follow those URLs without decoding or reconstructing the opaque cursor.
Invalid cursors
Malformed, unsigned, context-invalidated or overlong request values
throw InvalidCursorException. Let Symfony render the 400 response in
production. Do not silently restart on page one: that hides broken links
and can mislead API clients.
Changing the effective cursor secret, source context or effective order
invalidates existing tokens by design. With the built-in Doctrine
adapters, changing the query or its parameters changes the source context.
For a custom adapter, invalidation follows the value returned by
getCursorContext().