pugx/filter-bundle
PUGXFilterBundle brings annotation-driven filtering to Symfony and Doctrine: mark filterable entity fields, then render a form that automatically builds DQL queries from user input, giving you searchable, sortable list pages without hand-writing query logic.
composer require. pugx/filter-bundle is a Symfony bundle: it depends on the Symfony bundle/DI-extension model, the Symfony Form component, Doctrine ORM entity metadata, and the kernel event system. There is no service provider, no config/filter.php, and no Composer auto-discovery for Laravel. In a pure Eloquent app you will get zero usable behaviour out of the install.laravel-doctrine/orm — you can read the source and port the pattern, but wiring the actual bundle means hand-registering a Symfony DI extension inside Laravel's container. Budget this as a real project, not a five-minute install, and expect to maintain a bridge.FilterType form base class (how a search form is declared), FilterBuilder (how a submitted form becomes a Filter), the Filter value object (the immutable result you actually apply to a query), and the annotation/attribute classes (Contains, Equals, range, boolean, date). Those four concepts are the whole package; everything else is plumbing.^1.7.1. v1.7.1 fixed two things you care about immediately — forms are now keyed by name rather than by ordinal position (so two filters on one page don't collide), and filter state is reset between requests (so long-lived workers don't leak a previous query into the next response). Neither matters if you only port the pattern, but they're the reason the pattern is trustworthy.A minimal Eloquent port of the core idea, which is what "getting started" actually looks like for a Laravel dev:
final class FiltersQuery
{
public function __construct(
private array $request,
private array $allowed, // 'name' => 'like', 'status' => 'eq', ...
) {}
public function apply(Builder $query): Builder
{
foreach ($this->allowed as $field => $operator) {
$value = $this->request[$field] ?? null;
if ($value === null || $value === '') {
continue; // blank filter must mean "unfiltered"
}
match ($operator) {
'like' => $query->where($field, 'like', "%{$value}%"),
'prefix' => $query->where($field, 'like', "{$value}%"),
'eq' => $query->where($field, $value),
'gte' => $query->where($field, '>=', $value),
};
}
return $query;
}
}
FilterType-per-resource convention exists because a shared "god filter" makes the column allow-list grow silently — someone adds a field for screen A and screen B starts accepting it too. Mirror that discipline: App\Filters\UserFilters, App\Filters\OrderFilters, each with its own explicit map of input name → operator.handleRequest() → Filter → QueryBuilder. In Laravel it should be FormRequest (validation as the gate) → a small DTO → a QueryBuilder/Builder. Keep the DTO immutable so nothing downstream can mutate the filter mid-request; that's the property that makes the package's design hold up.User::filter($filters)->paginate() — so controllers never see where() calls and the whitelist lives in one testable place.name/id attributes stay predictable for JS and feature tests.?price_gt=0 against a column you never meant to expose. Map name → Contains on a specific column; never interpolate a request key straight into where().sort parameter against it, and fall back to a default. Contains and ORDER BY have different performance characteristics and different trust models.apply(Builder, mixed $value) contract. Reaching for if ($field === 'tags') inside a controller is the failure mode.FooFilterType extends FilterType, one form field per condition, register the type as a service, and instantiate the FilterBuilder per-request rather than holding it in a shared service.DependencyInjection extension and service definitions. There is no service provider to discover. If you install it into Laravel you will own the wiring, the upgrades, and the breakage — for a capability that is roughly 150 lines of your own code.filters_... ids, re-check selectors after upgrading. Naming filters explicitly yourself removes the surprise permanently.Filter object before it reaches the QueryBuilder, then enable SQL logging (doctrine.dbal.logging for Doctrine, DB::listen() or ->dd() for Eloquent) and diff the generated SQL against what you expected. The annotation/indirection layer makes it genuinely hard to eyeball which conditions actually fired — the value object dump is the fastest signal.WHERE when a value is present. Assert this explicitly; the opposite behaviour (empty result set on empty form) is a common regression when someone "simplifies" the apply loop.Contains produces LIKE '%term%', which cannot use a B-tree index. On large tables prefer prefix search (term%) or a full-text index, and confirm every filterable column is actually indexed. This is the single most likely production performance surprise, and it's a code-review discipline issue rather than a package defect.FiltersQuery as sketched above. Record pugx/filter-bundle as explicitly out-of-scope for Laravel work so it isn't re-proposed — and note the LGPL-3.0 licence if you do copy code rather than just the idea.How can I help you explore Laravel packages today?