ongr/pager-bundle
ONGR Pager Bundle adds flexible, configurable pagination to Symfony applications, with page ranges, page-size controls, and pager rendering. It integrates with Doctrine and Elasticsearch data sources, making it easy to build efficient list and search results pages.
First, the reality check: ongr/pager-bundle has historically been a Symfony bundle, not a Laravel package. The archived 2015 line targeted Symfony 2.x / PHP 5.5+ and declared dependencies on symfony/http-kernel, symfony/dependency-injection, etc. That is the shape of the code most documentation, blog posts and Stack Overflow answers still describe.
What v0.1.0 changes — and what it doesn't. The new tag is a version reset to 0.1.0 with a release note of literally "Initial release." There is no changelog entry, no upgrade guide, no stated PHP/Symfony constraint changes, and no indication of a namespace change or a Laravel port. Treat it as: a fresh tag exists, the substance is undocumented. Two practical consequences:
composer show ongr/pager-bundle --all and open the actual 0.1.0 tree. Confirm the class names (ONGR\Bundle\PagerBundle\ONGRPagerBundle, Pager, AdapterInterface), the config key, and the composer.json constraints. A 0.x "initial release" may be a repackage that no longer matches the Symfony-2-era registration flow.composer.lock.For a Laravel project the pragmatic "getting started" is unchanged: vendor the idea, not the package — the pagination core is two small interfaces you can port in an afternoon (MIT license, so copying is fine):
interface AdapterInterface {
public function getTotalCount(): int;
public function getSlice(int $offset, int $limit): array;
}
If you are actually on Symfony (or maintaining a legacy app), verify-then-install:
composer require ongr/pager-bundle:^0.1 — inspect the resolved constraints first; do not assume PHP 8 support.AppKernel, otherwise wire the pager as a plain service.config.yml key is not guaranteed to survive a reset.AdapterInterface for your data source — this is the only thing you must write.Pager class with your adapter + page size.$page = $pager->getPage($request->query->get('page', 1)); then pass $page->getResults() to the view.In Laravel, the equivalent first use case looks like this — implement the adapter against Eloquent and hand the result to Laravel's paginator:
$total = $query->count();
$offset = ($page - 1) * $perPage;
$items = $query->offset($offset)->limit($perPage)->get();
$results = new LengthAwarePaginator($items, $total, $perPage, $page, ['path' => url()->current()]);
That gives you {{ $results->links() }}, ->withQueryString(), and API resource integration for free, which is the whole point.
Adapter-per-query. Don't make the adapter a long-lived singleton holding a query builder. Build it per request (or via a factory that takes the filtered query), because the pager is stateful: it asks the adapter for getTotalCount() and getSlice() in sequence, and the same adapter instance is meant to represent one result set.
Bridging to Laravel's paginator is the winning pattern. Wrap the pager output so the rest of your app never sees ONGR types:
class PagerBridge {
public function paginate(AdapterInterface $adapter, int $page, int $perPage): LengthAwarePaginator
{
$page = max(1, $page);
return new LengthAwarePaginator(
$adapter->getSlice(($page - 1) * $perPage, $perPage),
$adapter->getTotalCount(),
$perPage,
$page,
['path' => request()->url(), 'query' => request()->query()]
);
}
}
Now every Blade view, API resource, and test uses the standard $items->total(), $items->lastPage() API.
Eloquent adapter skeleton. Keep the count query and the slice query structurally identical — clone the builder, strip orderBy from the count clone (it's wasted work and some DBs reject ordering on aggregate queries):
final class EloquentAdapter implements AdapterInterface {
public function __construct(private Builder $query) {}
public function getTotalCount(): int {
return (clone $this->query)->reorder()->count();
}
public function getSlice(int $offset, int $limit): array {
return $this->query->offset($offset)->limit($limit)->get()->all();
}
}
Search/ES workflow (the package's original home turf). ONGR built this to sit on top of Elasticsearch + FilterManager. The pattern there is: filters build the query → adapter wraps the ES query → pager executes count then from/size → controller renders. If you're replicating it in Laravel, the same shape works with laravel/scout or elasticsearch-php: your adapter holds the prepared search body and just swaps from/size.
Controller workflow.
max(1, (int) request('page', 1))).page > lastPage, either redirect to lastPage or return 404 — pick one and be consistent.Resource::collection($paginator) over hand-rolling meta.Form/request-object integration. Bind the page size to a validated request parameter with a whitelist ([10, 25, 50, 100]) rather than trusting the client — the "per page" argument is an easy injection point for huge LIMIT values.
If you adopt 0.x, isolate it. Wrap the package behind your own PagerBridge/adapter seam and never let ONGR\* types leak into controllers, resources or Blade. That way a breaking 0.2.0 — which semver explicitly permits — costs you one file, not a refactor.
A 0.1.0 "Initial release" is not a revival you can rely on. No changelog, no upgrade notes, no stated support policy, no security process, no maintainer activity signal, 0.x versioning, and a decade of dormancy behind it. Verify the tag actually modernises constraints before assuming it fixes PHP 8 / Symfony 6 conflicts. Default posture: port the ~100 lines of pagination logic instead of fighting Composer.
Old docs may now be actively wrong. Every tutorial referencing AppKernel registration or a specific config.yml key was written against the Symfony 2 line. After a version reset, those samples are unverified. Read the installed source for class names, config keys and defaults before copying any snippet — including the ones in this assessment (that's why step 1 is "list and inspect").
Page numbers are 1-based; offsets are 0-based. The classic off-by-one: $offset = ($page - 1) * $perPage. Every "page 1 shows page 2's data" bug lives here.
Divide-by-zero on getTotalPages(). If perPage is 0 or missing (easy when the config key isn't loaded — very plausible after a version reset changed the key), total-pages math blows up. Validate perPage > 0 at the boundary and default it explicitly.
The pager is stateful — don't share it across queries. Reusing one pager service for two different result sets in the same request yields the second query's slice with the first query's total count. Instantiate per result set.
Count/slice mismatch is the #1 bug. If your filters are applied to the slice query but not the count query (common when you clone after an apply() that mutates state, or when you forget reorder()), your paginator shows 500 results but only 3 pages. Debug by logging getTotalCount() alongside the slice length and asserting count <= total.
Counting is often the expensive half. On large tables, COUNT(*) over a complex filtered join can dominate request time. Cache it per filter-hash for a few seconds, or use an approximate count on very large datasets and accept slightly imprecise lastPage().
Deep pagination limits. If the data source is Elasticsearch, from + size is capped by index.max_result_window (default 10,000) — page 201 of 50-per-page will throw. Switch to search_after for deep traversal; the ONGR-style adapter interface can't express that, which is one reason not to be sentimental about it.
Symfony request-listener magic doesn't translate. In the original bundle, a kernel listener can pull the page out of the request and stash a Page object in request attributes, so controllers don't touch the query string. In Laravel, do the equivalent explicitly in the controller or a form request — implicit request mutation is harder to trace and harder to test. Also re-check whether that listener still exists in 0.1.0; a reset release is exactly where event-listener wiring silently disappears.
The AdapterInterface is the only real extension point. Everything else (pager, page value object, listeners) is a thin shell around it. If you're porting, keep that seam exactly as-is: one adapter per data source (Eloquent, Scout, ES, Redis, external API), one generic pager. That's the entire architectural value of the package, and it is unaffected by the release.
Debugging checklist when a page renders empty: (a) is the page param actually arriving? (b) did you clamp page >= 1? (c) is page > lastPage? (d) did the adapter apply the same filters as the count? (e) on ES, did the query body get reset between count and slice calls (some clients clear from/size on reuse)? (f) after the 0.1.0 upgrade, did the config key or service wiring change out from under you?
Licensing is your friend here. MIT means you can copy the AdapterInterface + Pager pattern verbatim into your Laravel app with attribution — still a better outcome than maintaining a dormant 0.x dependency, unless the 0.1.0 tag turns out to come with a real changelog and an active maintainer.
How can I help you explore Laravel packages today?