gloomy/pager-bundle
gloomy/pager-bundle provides lightweight, reusable pagination for PHP applications. Split large result sets into manageable pages, configure page sizes, and generate clean pager links for easy integration into controllers and templates. Simple setup and flexible pager output make it practical for Sy...
Read this first: it is not a Laravel package. gloomy/pager-bundle is a Symfony bundle — it registers through a Symfony kernel, resolves services from Symfony's DI container, reads state from Symfony\Component\HttpFoundation\Request, and ships Twig templates for the pager UI. composer require-ing it into a Laravel app pulls Symfony HttpFoundation, DependencyInjection, and Twig in to serve one list screen. Don't do that. Treat the repo as a design reference and budget a 1–2 day spike to read it, not an afternoon to install it.
Where to look first, in this order:
QueryBuilder. Everything else (pager UI, CRUD scaffolding, Twig) is replaceable.Request/session? A clean value object ports to Laravel trivially; a session-coupled one does not.composer.json — PHP and Symfony constraints tell you the vintage and whether the code is even syntactically viable on your runtime.First use case to aim at: an internal admin/ops list screen — orders, users, audit log — where support staff need per-field filters (status equals X, created between A and B), multi-column sort, and pagination. That's the exact shape this package targets, and it's the screen where hand-rolled filtering reliably turns into spaghetti.
First concrete step in Laravel is not code from the package at all. Define the contract you want, then decide whether the package's internals are worth porting:
// app/Support/Listing/Contracts/DataSource.php
interface DataSource
{
/** @param array<string, mixed> $filters */
public function apply(array $filters, array $sorts): static;
/** @return LengthAwarePaginator<int, mixed> */
public function paginate(int $page, int $perPage): LengthAwarePaginator;
}
Two implementations — CollectionDataSource and EloquentDataSource — and you have reproduced the package's central idea in ~200 lines you own. Start there. If the spike shows the package's array-side filtering logic is genuinely better than what you'd write, port it with the MIT notice retained.
If you only need pagination, stop entirely: Model::paginate() and LengthAwarePaginator already do this, and spatie/laravel-query-builder covers filter/sort on top of Eloquent with an active maintenance story.
Filter definitions as data, not conditionals. The package's transferable move is that a filter set is a declarative structure that resolves against a source. In Laravel that means keeping controllers free of if ($request->filled('status')) chains:
final class FilterSet
{
/** @param array<string, Filter> $filters */
public function __construct(private array $filters) {}
public static function fromRequest(Request $request, array $allowed): self
{
$applied = [];
foreach ($allowed as $field => $filter) {
if ($request->has($field)) {
$applied[$field] = $filter->withValue($request->input($field));
}
}
return new self($applied);
}
public function applyTo(DataSource $source): DataSource
{
return $source->apply(
array_map(fn (Filter $f) => $f->value(), $this->filters),
// sorts resolved the same way
);
}
}
The allowlist is the security boundary. Never let a request key become a column name directly; resolve it through the map of known filters. The package's own handling of this is the thing to verify during the spike, and the thing to be strict about regardless.
Eloquent adapter — the one adapter you actually need.
final class EloquentDataSource implements DataSource
{
public function __construct(private Builder $query) {}
public function apply(array $filters, array $sorts): static
{
foreach ($filters as $field => $value) {
match ($this->operators[$field] ?? 'eq') {
'eq' => $this->query->where($field, $value),
'like' => $this->query->where($field, 'like', "%{$value}%"),
'between'=> $this->query->whereBetween($field, $value),
'in' => $this->query->whereIn($field, (array) $value),
};
}
foreach ($sorts as $column => $direction) {
$this->query->orderBy($column, $direction === 'desc' ? 'desc' : 'asc');
}
return $this;
}
}
Keep relation filters as explicit, named filters (customer.country → whereHas) rather than parsing dotted paths at runtime — that's where generic filter builders start leaking SQL structure into user input.
Collection adapter — use it for tests and cached sets, not primary data. The array side earns its keep in exactly two places: unit-testing filter semantics without a database, and paginating already-materialized results (a cached API aggregation, a federated search merge). Mirror the Eloquent semantics exactly so a test against CollectionDataSource is a real assertion about production behavior. That parity is the whole reason to bother.
Sort state belongs in the URL, not the session. Render pagination and sort links as query-string mutations so screens are shareable and back-button-safe. In Blade:
{{-- resources/views/partials/sortable.blade.php --}}
@php
$next = $current === 'asc' ? 'desc' : 'asc';
$query = array_merge(request()->query(), ['sort' => $column, 'dir' => $next]);
@endphp
<th>
<a href="{{ request()->url().'?'.http_build_query($query) }}">
{{ $label }}
@if ($current) <span class="sort-{{ $current }}">↕</span> @endif
</a>
</th>
Discard the bundle's Twig templates entirely — they're documentation of intent, not assets. Laravel's {{ $paginator->withQueryString()->links() }} already preserves filter state, which is the one UI behavior people forget to carry over.
Wire it through a query object, not the controller. A ListOrdersQuery that takes a FilterSet, a SortSet, and page params, and returns a paginator, keeps the controller to three lines and makes the whole list layer testable against a CollectionDataSource fixture.
The dependency is the trap; the pattern is the prize. 10 stars, 0 dependents, single maintainer. Adopting it means forking it, and forking a Symfony bundle into a Laravel app means owning code written in an idiom your team doesn't use daily. Read it, extract the idea, close the tab. Document the decision in an ADR or a future maintainer will wonder why Laravel filtering logic cites a Symfony bundle.
COUNT(*) is the hidden cost of every filtered page. Both Doctrine and Eloquent run a second aggregate query per page by default. On a large table with multi-column filters it routinely dominates latency. If the datagrid is deep-pageable, use simplePaginate() (skips the count) or keyset pagination — the bundle will not solve this for you and neither will a naive port.
Array pagination does not scale, at all. Filtering, sorting, and slicing an in-memory array is O(n) per request with the whole dataset resident. Fine for a few thousand cached rows; catastrophic as a path to a primary source. Put a hard cap on the collection size and document the threshold above which the endpoint must move to a query source. The package's array adapter has the same characteristic — it's a feature of the approach, not a bug in the code.
Don't let user input name a column. Filter keys and sort keys must resolve through an allowlist map. If the spike reveals the bundle passes field names straight into query construction, that's a reason not to port that particular file. Laravel-specific footgun on top: orderBy with an unvalidated column is a straightforward injection vector, and ->orderBy($request->sort) is a one-line mistake people make while "just wiring it up."
Version skew with shared Symfony components. If you do end up pulling any part of it in, it will drag symfony/http-foundation (already present in Laravel) and possibly symfony/console. Pin to an exact version, never a caret, against an unmaintained package — a caret range plus a Laravel upgrade that bumps HttpFoundation is a class of breakage with no upstream fix coming.
MIT obligations on a port. Retain the copyright notice if you copy code, and note in the file that it's derived — internal code review will otherwise flag unfamiliar-looking filtering logic. A ported fork also nominally inherits the job of tracking upstream security fixes; here that obligation is effectively empty, which is itself worth writing down.
Modern Symfony alternatives exist; Laravel ones are better for you. If this ever comes up for a Symfony service, EasyAdmin, SonataAdmin, and API Platform's filter system are maintained and documented. On Laravel, the equivalents are Filament (whole admin surface), spatie/laravel-query-builder (filter/sort on Eloquent), and native pagination. Reaching past all three for a near-dormant bundle is a poor trade in every direction except "I want to study a clean filter abstraction," which is the one thing it's genuinely good for.
Extension point worth keeping from the package: the DataSource split. Once CollectionDataSource and EloquentDataSource exist behind one interface, adding a third source (an external API response, a search index result set) is a single class, and every existing filter and test applies unchanged. That leverage — not the CRUD scaffolding — is the package's actual contribution, and it's the piece to carry into your own codebase.
How can I help you explore Laravel packages today?