Cursor Pagination
Move through an ordered feed without a count query. The signed cursor records a stable boundary, so an earlier deletion does not shift the current slice.
ux.symfony.com
20 ordered values 5 values per slice
The window moves. The grid does not.
- 20
- 19
- 18
- 17
- 16
- 15
- 14
- 13
- 12
- 11
- 10
- 9
- 8
- 7
- 6
- 5
- 4
- 3
- 2
- 1
- Visible now
- Next boundary
Define one stable order
Pass the original source, define a deterministic order, and choose the slice
size. paginate() reads the incoming ?cursor automatically.
For an array source, context() gives this logical feed a stable identity. The
cursor token is signed and tied to that context and order.
32$withDeletedItem = $request->query->getBoolean('deleted');
33$pagination = $paginator
34 ->cursor($releases->all($withDeletedItem))
35 ->orderBy('id', 'DESC')
36 ->perPage(5)
37 ->context('demo-cursor')
38 ->queryParameters(array_filter(['deleted' => $withDeletedItem ? 1 : null]))
39 ->paginate();
Render the slice and its links
The result stays iterable. Render it like any other collection, then pass the
same object to ux_pagination() for ordinary Previous and Next links.
Each link carries an opaque cursor. Your Twig template never decodes it or reconstructs the boundary.
{% for item in pagination %}
<article>Item {{ item.id }}</article>
{% endfor %}
{{ ux_pagination(pagination) }}
Author
smnandre
Published
2026-09-21