javihgil/doctrine-pagination-bundle
A Symfony bundle that adds simple pagination for Doctrine queries, helping you split ORM and DBAL results into pages with a reusable paginator service. Ideal for building paged listings, search results, and admin grids without writing custom limit/offset logic.
The bundle is a thin wrapper over Doctrine\ORM\Tools\Pagination\Paginator: you hand it a QueryBuilder plus a page number and page size, and it hands back something you can iterate and query count() / getTotalPages() on. That's the whole mental model — don't expect filtering DSLs, sort whitelisting, or Twig widgets unless you add them.
1. Install and register
composer require javihgil/doctrine-pagination-bundle
This is a low-profile package (no Flex recipe), so add it to config/bundles.php manually unless it's already there:
JavihGil\DoctrinePaginationBundle\DoctrinePaginationBundle::class => ['all' => true],
2. Find the service definition before writing any code
The public API of a bundle this size is its service definition, not its README. Open vendor/javihgil/doctrine-pagination-bundle/Resources/config/ (or config/) and look at the XML/YAML. You want two things:
javihgil_doctrine_pagination.paginator, so __construct(Paginator $p) will fail without an alias).If autowiring isn't set up, add the alias yourself in config/services.yaml:
services:
JavihGil\DoctrinePaginationBundle\...\Paginator:
alias: javihgil_doctrine_pagination.paginator
public: true
3. First use case: paginate a repository query
// src/Repository/ProductRepository.php
public function findPage(int $page, int $perPage): Paginator
{
$qb = $this->createQueryBuilder('p')
->leftJoin('p.category', 'c')->addSelect('c')
->orderBy('p.createdAt', 'DESC')
->addOrderBy('p.id', 'DESC'); // tiebreaker — see Gotchas
return $this->paginator->paginate($qb, $page, $perPage);
}
// src/Controller/ProductController.php
#[Route('/products', name: 'product_index')]
public function index(Request $request, ProductRepository $products): Response
{
$page = max(1, $request->query->getInt('page', 1));
$perPage = min(100, max(1, $request->query->getInt('per_page', 20)));
return $this->render('product/index.html.twig', [
'products' => $products->findPage($page, $perPage),
]);
}
Repository-owns-pagination. Keep page/per-page parsing in the controller and the paginator call in the repository. Controllers stay thin, and every list endpoint gets the same clamping logic. If several repositories need it, put the paginate() helper on an abstract PaginatedRepository base class.
Two shapes of result. Almost every consumer needs only three things: the current slice (iterable), the total item count, and the total page count. Normalise them once so Twig and your API layer don't reach into the paginator differently:
final class Page implements \IteratorAggregate
{
public function __construct(
public readonly iterable $items,
public readonly int $total,
public readonly int $page,
public readonly int $perPage,
) {}
public function getIterator(): \Traversable { return new \ArrayIterator(iterator_to_array($this->items)); }
public function lastPage(): int { return max(1, (int) ceil($this->total / $this->perPage)); }
}
JSON APIs. Iterate the paginator into your serializer and emit a meta block: page, per_page, total, last_page. Don't serialize the Paginator itself — it carries the Query and will blow up or leak SQL.
Twig partial. One _pagination.html.twig that takes the paginator plus the route name and renders prev/next and a windowed page range. Because this bundle doesn't ship a widget, this is your UI layer — build it once.
Query shaping tips that pay off daily
->addSelect('c')); for to-many use a separate hydration step or setFetchJoinCollection(false).QueryBuilder over; the paginator will reuse the same Query for the count, so anything added after is ignored or diverges.COUNT(*) over the same WHERE clause is often faster than the paginator's count query — swap it in if you expose a custom count.Always order deterministically. Without a unique tiebreaker in ORDER BY, rows shuffle between pages as data changes, and you get duplicate/missing items. ORDER BY id at the end costs nothing and fixes a whole class of "the pagination is broken" bugs.
Joins + collections produce wrong totals. Doctrine's Paginator needs setUseOutputWalkers(false) (and sometimes setFetchJoinCollection(false)) when you join to-many associations; otherwise the COUNT query can be invalid or wildly expensive on MySQL/Postgres. The bundle may or may not expose these — if it returns the raw Doctrine paginator, call them yourself before iterating.
DISTINCT and fetch-joins don't mix cheaply. A fetch-joined collection forces output walkers, which means loading every row of the page into memory to count. For large result sets, paginate IDs first, then hydrate the page in a second query.
This package is effectively unmaintained. Last release Dec 2022, zero stars, zero dependents, no test suite. Treat it as a convenience wrapper you could replace in an afternoon: the fallback is Doctrine\ORM\Tools\Pagination\Paginator directly, and the migration is mechanical. Don't let its class names leak into domain code — wrap it behind your own Page/PaginatorInterface so a swap is a one-file change.
Check autowiring and service visibility first. Most "it doesn't work" reports on bundles like this are an ServiceNotFoundException. Run bin/console debug:container | grep -i paginat before debugging anything else.
Debug the generated SQL, not the PHP.
$qb->getQuery()->getSQL(); // the slice query
// count query = clone the QB, replace select with COUNT(...)
If the count is wrong, it's almost always a join fan-out or a missing GROUP BY, not the bundle.
Configuration quirks. Look for a default_per_page style parameter in the bundle's extension class — but don't rely on it; pass perPage explicitly from your own config so changing the bundle version can't silently change your page sizes.
Extension points are the standard Symfony three: decorate the paginator service to add metrics/logging, override any Twig template the bundle registers via twig.paths, and bind globals in config/packages/twig.yaml if you want the page size available in templates without threading it through every action.
How can I help you explore Laravel packages today?