knplabs/knp-paginator-bundle
Friendly Symfony paginator that paginates everything. Built on the Knp Pager component, it offers customizable pagination views, event subscribers, plus filtering and sorting, with no adapter setup required. Supports Symfony 6.4+, Twig 3, and ORM/ODM queries.
Install and confirm the wiring (5 minutes).
composer require knplabs/knp-paginator-bundle
With Flex there is no bundle registration step. The service you care about is knp_paginator, exposed as Knp\Component\Pager\PaginatorInterface, so constructor injection Just Works:
use Knp\Component\Pager\PaginatorInterface;
public function __construct(private PaginatorInterface $paginator) {}
Do not bother with the knp_paginator.injectable tag or PaginatorAwareInterface — the README explicitly discourages them and they may disappear. Autowire the interface.
The only thing you must unlearn from hand-rolled pagination: you pass the query, never the result.
#[Route('/articles', name: 'article_list')]
public function list(Request $request, ArticleRepository $repo, PaginatorInterface $paginator): Response
{
$qb = $repo->createQueryBuilder('a')
->leftJoin('a.author', 'author')
->addSelect('author'); // fetch-join the relation you render
$pagination = $paginator->paginate(
$qb, // QueryBuilder, Query, DBAL QB, array, ArrayCollection...
$request->query->getInt('page', 1),
20 // per page
);
return $this->render('article/list.html.twig', ['pagination' => $pagination]);
}
{# <head> — emit rel=prev/next for crawlers #}
{{ knp_pagination_rel_links(pagination) }}
<table>
<thead>
<tr>
<th>{{ knp_pagination_sortable(pagination, 'Id', 'a.id') }}</th>
<th{% if pagination.isSorted('a.title') %} class="sorted"{% endif %}>
{{ knp_pagination_sortable(pagination, 'Title', 'a.title') }}
</th>
<th>{{ knp_pagination_sortable(pagination, 'Author', 'author.name') }}</th>
</tr>
</thead>
<tbody>
{% for article in pagination %}
<tr><td>{{ article.id }}</td><td>{{ article.title }}</td><td>{{ article.author.name }}</td></tr>
{% else %}
<tr><td colspan="3">{{ 'No results'|trans }}</td></tr>
{% endfor %}
</tbody>
</table>
{{ knp_pagination_render(pagination) }}
First day-one tuning: pick a real pagination template instead of the default sliding one. knp_paginator.template.pagination: '@KnpPaginator/Pagination/tailwindcss_pagination.html.twig' (or bootstrap_v5, bulma, uikit_v3, semantic_ui, materialize, foundation_v6 — all ship in the bundle). That single config line replaces whatever hand-written paginator markup your app currently has.
Config lives under config/packages/knp_paginator.yaml; the defaults (page, sort, direction, filterField, filterValue, default_limit: 10) are sane and only worth overriding if a legacy URL scheme must keep working.
Repository-level pagination is the pattern to standardise on. Keep the query construction in the repository, the paginate call in the controller. It makes the "which fields are sortable" whitelist colocated with the query that defines the aliases.
Whitelist sort fields — this is not optional. sort and direction come straight off the query string into the DQL ORDER BY. Knp does not validate them. The cheapest correct pattern:
private const SORTABLE = [
'id' => 'a.id',
'title' => 'a.title',
'date' => 'a.publishedAt',
'author' => 'author.name',
];
$sort = self::SORTABLE[$request->query->get('sort', 'date')] ?? 'a.publishedAt';
$dir = strtolower((string) $request->query->get('direction')) === 'asc' ? 'asc' : 'desc';
$qb->orderBy($sort, $dir);
Then pass ['sortFieldParameterName' => ...]-style options, or more simply render sortable links using your whitelist keys so the URL always carries a known-good value. Never let a raw request value reach orderBy().
Multiple paginators on one page (e.g. "latest articles" + "latest comments"): pass per-paginator options so the query parameters don't fight over each other.
$paginator->paginate($articlesQb, $page, 10, [
'pageParameterName' => 'articlePage',
'sortFieldParameterName' => 'articleSort',
'sortDirectionParameterName' => 'articleDirection',
]);
Arrays and collections are first-class inputs. This is where Knp beats writing it yourself: paginate a merged collection, a search API result, an ArrayCollection, or a Solarium result with the same API and the same Twig templates. Useful for faceted search where you merge an ES result set with a DB hydration pass.
For JSON/API responses, don't render — read the object. PaginationInterface gives you getItems(), getTotalItemCount(), getCurrentPageNumber(), getItemNumberPerPage(), getPaginationData(). Return those as {data, meta: {page, limit, total, pages}}. getPaginationData() already computes pageCount, previous, next, first, last and is a decent drop-in for a HAL-style meta block.
Custom pagination subscribers let you support an input type the bundle doesn't know about. Register a service tagged knp_pager.event_subscriber (or implementing EventSubscriberInterface with knp_pager.before / knp_pager.after), inspect the ItemsEvent, and either set $event->items / $event->count and call $event->stopPropagation(), or modify the item list after the fact (e.g. hydrate extra fields onto the page of results without a second query). This is the intended extension point for "paginate a third-party API" — see docs/custom_pagination_subscribers.md.
Filtration is deliberately bare: a form that GETs filterField/filterValue back to the same route, rendered from knp_paginator.template.filtration. Most teams replace it with a proper Symfony form and map the submitted filters onto the query builder before calling paginate(). Use the bundle's filtration template only for trivial single-column search.
Override templates in the app, not in vendor: templates/bundles/KnpPaginatorBundle/Pagination/sliding.html.twig. Override translations in translations/KnpPaginatorBundle.en.yaml (label_previous, label_next, filter_searchword).
With Turbo/HTMX: the generated links are plain GET anchors with sort+page query params. Wrapping the list in a Turbo Frame works out of the box because the anchors are inside the frame — no JS needed, but make sure knp_pagination_render's output stays inside the frame element.
Never paginate $qb->getQuery()->getResult(). Paginating a result array silently fetches the whole table into PHP memory first. The paginator will happily do it, and your staging box won't notice.
distinct: true is the default and it is the #1 source of "why does my count say 47 when there are 12 rows". With GROUP BY / fetch-joined collections, Doctrine's count query can throw Cannot count query which selects two FROM components or Not all identifiers in the SELECT clause are.... Fix order of preference: (1) select DISTINCT a explicitly, (2) drop the collection fetch-join and let Doctrine lazy-load per row (N+1 for one page — usually fine at 20 rows), (3) pass ['distinct' => false] to paginate() and accept the row multiplication, (4) supply a bespoke count query.
Sorting by a joined field requires the join to exist in the query, and the alias in the sort argument must match. knp_pagination_sortable(pagination, 'Author', 'author.name') breaks unless you leftJoin('a.author', 'author') in the controller. The failure mode is a DQL Semantical Error — an unhandled 500 on any sortable link, not a graceful fallback.
page_out_of_range defaults to ignore, which means ?page=9999 renders an empty table with a 200. For SEO and for API stability set page_out_of_range: throwException plus convert_exception: true so deep/negative pages 404 instead of minting infinite soft-404 URLs. If you'd rather keep it a 200, use fix to clamp to the last page.
Count queries are the cost. One extra SELECT COUNT(...) per request. On large tables, make sure the filters are indexed, and don't paginate an unindexed WHERE ... LIKE '%foo%'. If the count itself is the bottleneck, cache getTotalItemCount() per filter combination rather than swapping paginator.
Validate limit from the request. If you do $request->query->getInt('limit', 20), someone will send ?limit=100000. Clamp it against an allowed set ([10, 25, 50, 100]) before handing it to paginate().
Out-of-date component. The bundle requires knplabs/knp-components >=4.4 and must move in lockstep with it; a composer update that bumps one but not the other produces confusing adapter behaviour. Pin both or let Composer resolve them in the same update run.
Bundle is quiet, not dead. Last release is dated 2026 with master on Symfony >=6.4 — stable and maintained, but the API has barely moved in years. If you want a low-effort contribution, the docs around multiple-paginator options and the alias vs. distinct-parameter-names story are genuinely thin, and the "opportunity" ceiling of this package is mostly documentation and Symfony UX integration, not features.
When not to use it: single-table, no-sort, no-filter listings. Doctrine\ORM\Tools\Pagination\Paginator + a Pagerfanta template does the job with one less dependency. Knp earns its place when you need sortable/filterable links, rel-links, and non-Doctrine inputs (arrays, collections, DBAL) behind one API — and when you want the 13 shipped CSS-framework templates instead of writing them yourself.
Testing: functional-test the query string, not the paginator. $client->request('GET', '/articles?page=2&sort=title&direction=asc') and assert which items render plus assertSelectorExists('link[rel="next"]'). An out-of-range assertion (?page=999 → 404) belongs in the same test once you've set the config above.
How can I help you explore Laravel packages today?