DEMOS / Pagination

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.

  1. 20
  2. 19
  3. 18
  4. 17
  5. 16
  6. 15
  7. 14
  8. 13
  9. 12
  10. 11
  11. 10
  12. 9
  13. 8
  14. 7
  15. 6
  16. 5
  17. 4
  18. 3
  19. 2
  20. 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();

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