symfony/ux-pagination
Symfony UX Pagination provides Twig components and helpers for adding pagination to Symfony applications. It simplifies navigating paged datasets such as lists and search results, with customizable controls that fit your templates and design.
Frame the target stack before you type composer require. This is a Symfony UX package, not a Laravel one. It depends on symfony/http-foundation, symfony/options-resolver, the Twig bridge, and Doctrine. Its UI tier is Twig components, not Blade. There is no Laravel service provider and no Laravel adapter. So "getting started" splits into two paths:
Pager + adapter) headless and throw away the rendering tier, which is the majority of the package's value. Laravel already ships paginate() / cursorPaginate() with Blade views. Do the cost/benefit before pulling anything in.composer require symfony/ux-pagination — Flex auto-registers the bundle; no manual bundles.php edit in a standard app.assets/, and PHP/asset version skew is the classic UX failure mode.src/Pager.php — the object you actually pass to your controller and template.src/AdapterInterface.php — the seam you'd implement to paginate anything non-Doctrine.src/Adapter/Doctrine/ — QueryBuilderAdapter (numbered) and the cursor adapter.templates/components/ — the numbered and cursor Twig component templates you'll be overriding.Pager, render the component.// Controller — illustrative shape; verify the exact constructor against src/Pager.php
$qb = $postRepository->createQueryBuilder('p')
->where('p.published = :published')
->setParameter('published', true)
->orderBy('p.createdAt', 'DESC')
->addOrderBy('p.id', 'DESC'); // tiebreaker — non-negotiable, see Gotchas
$pager = new Pager(
adapter: new QueryBuilderAdapter($qb),
pageSize: 20,
);
$pager->setPage($request->query->getInt('page', 1));
return $this->render('post/index.html.twig', ['pager' => $pager]);
{% for post in pager %} {# Pager is iterable: yields the current page #}
{{ include('post/_card.html.twig', { post: post }) }}
{% endfor %}
{{ component('Pagination', { pager: pager }) }}
That is the whole happy path. The first thing to confirm once it renders is that getNbResults() matches what you expect — see the count-query warning below.
Keep HTTP out of the pagination object's construction. Repositories should return queries; a single injectable factory should turn (query, page, size) into a Pager. That gives you one place to change page size, one place to add sorting whitelists, and one place to log slow counts.
final class PaginatorFactory
{
public function __construct(private int $defaultPageSize) {}
public function paginate(QueryBuilder $qb, Request $request, ?int $size = null): Pager
{
$pager = new Pager(new QueryBuilderAdapter($qb), $size ?? $this->defaultPageSize);
$pager->setPage(max(1, $request->query->getInt('page', 1)));
return $pager;
}
}
Drive defaultPageSize from configuration, not literals in controllers — you will want per-endpoint sizes (20 for lists, 5 for sidebars) without a code change.
Use the numbered adapter for CRUD screens, the cursor adapter for feeds. Numbered pagination gives you "page 4 of 37" — that's what admin tables and search results need. Cursor pagination gives you a stable nextCursor and no OFFSET scan — that's what infinite scroll, activity feeds, and any append-heavy table needs. Do not use one for the other's job.
Cursor pattern — return JSON, skip the Twig component entirely.
$cursor = $request->query->get('cursor');
$pager = new Pager(new CursorAdapter($qb), pageSize: 50);
// $pager->getCurrentPageResults() === this batch
// $pager->getNextCursor() === string for the next request (null at end)
return $this->json([
'items' => $pager->getCurrentPageResults(),
'nextCursor' => $pager->getNextCursor()?->toString(),
]);
The client sends ?cursor=<opaque string> back. Treat the cursor as opaque — never parse or construct it by hand in JS.
Rendering and overrides. Override templates by placing files under templates/bundles/UXPaginationBundle/components/ in the app; the per-strategy files (numbered.html.twig, cursor.html.twig) let you restyle markup without touching the component class. For one-off layouts, pass a different template to the component rather than forking the bundle's template.
If you are bridging into Laravel anyway, implement AdapterInterface (~30 lines) over Illuminate\Database\Query\Builder and use only the Pager as a value object. Do this only if you specifically want the package's PaginationInterface contract in shared PHP code; otherwise Laravel's own paginator already covers every case here and you avoid running two pagination paradigms side by side.
Avoid paginating across row-multiplying joins. LEFT JOIN ... comments plus OFFSET produces duplicate or short pages because the page boundary is applied to joined rows, not entities. If you must join, paginate a subquery of IDs first, then hydrate entities by those IDs — or use DISTINCT and accept the extra sort cost.
LengthAwarePaginator compatibility, no Laravel service provider. Copy-pasting KnpPaginatorBundle tutorials will also misfire — the class namespaces and Twig component names are different even where the concepts overlap.DATETIMETZ_MUTABLE columns changed behavior in v3.6.0 (#3903). That was a correctness fix, which means pre-3.6 output was wrong. If you paginate over timezone-aware datetime columns, upgrading will silently shift page boundaries: rows can reorder, appear, or vanish at page seams. Write a regression test that asserts boundary rows across a page transition before you bump the constraint.ORDER BY. ORDER BY created_at DESC alone means ties are ordered arbitrarily, and rows straddling a page boundary get duplicated or skipped. Add , id DESC to every paginated query. This is the single most common cause of "the pagination is broken" bug reports against any paginator.getNbResults() issues a COUNT query. On large tables that dominates page latency. For cursor-based endpoints, prefer hasNextPage/getNextCursor semantics and don't compute a total at all. If you need a total and it's expensive, cache it or approximate it — don't call getNbResults() from the template on every request without knowing what it costs.defaultPriorityMethod before upgrading. v3.6.0 (#4079) removed it from the adapter iterator, labelled a bug fix. Any subclass or call site will fatal. It's a loud failure, so CI catches it — but only if the code path is exercised.assets/ changed and re-publish if so (importmap vs Encore differ here). A wrong template override path fails the same silent way: the bundle falls back to its default template and you get correct markup with the wrong look. Verify the override actually took effect in the rendered HTML, not just by eyeballing it.?page=99999 or a garbage cursor should be a 404 or a redirect to the last page — verify what the pager does rather than assuming, because the default is usually "empty result set," which looks like a bug to users and to crawlers.getNbResults(), getNbPages(), getPage(), and count($pager->getCurrentPageResults()). If the last one differs from pageSize on a non-final page, your adapter or your ORDER BY is the problem, not the template. For Doctrine, inspect $qb->getQuery()->getSQL() to confirm the LIMIT/OFFSET land where you expect.AdapterInterface (custom data sources), Twig template overrides (presentation), and the Pager object itself (which is iterable, so a plain foreach in Twig works without any helper). There is no plugin system, no event system, and no query-optimization layer — if you need those, you're writing them.cursorPaginate() already covers the cursor use case with zero new dependencies.How can I help you explore Laravel packages today?