gns/filter-bundle
gns/filter-bundle provides reusable filter definitions for Symfony and Doctrine, letting you apply request-based criteria to query builders and listings. It keeps search and filtering logic centralized, configurable, and out of controllers.
Reality check before anything else: gns/filter-bundle is a Symfony bundle, not a Laravel package. It ships no ServiceProvider, no facade, no config publish tag, and its service wiring is done through Symfony's DependencyInjection extension + bundle registration in config/bundles.php. composer require gns/filter-bundle inside a Laravel app will install the source but give you nothing usable — the container won't boot its services, and any filter that assumes Doctrine's QueryBuilder/EntityManager has no analogue in Eloquent. Treat this as a reference implementation to port, not a dependency to install.
Where to look first, in this order:
README.md — the only documentation. Confirm whether filters are declared via annotations/attributes, YAML config, or explicit service definitions.src/ — the filter base class/interface and any DependencyInjection/ extension. This is the actual spec you'll be reimplementing.composer.json — check require for symfony/http-kernel, doctrine/orm, and the PHP constraint. That tells you how much of the design is Doctrine-shaped.First use case to target: an index endpoint that currently hand-rolls query-string handling, e.g. GET /api/orders?status=paid&created_after=2024-01-01&sort=-total. That is exactly the problem class this bundle addresses, and it's the one where a port pays for itself immediately.
Minimal path to a working equivalent in Laravel:
// app/Filtering/Filter.php
interface Filter
{
public function key(): string; // query param name
public function apply(Builder $q, mixed $value): void;
}
// app/Filtering/FilterPipeline.php
final class FilterPipeline
{
/** @param Filter[] $filters */
public function __construct(private array $filters) {}
public function apply(Builder $query, array $input): Builder
{
foreach ($this->filters as $filter) {
if (array_key_exists($filter->key(), $input) && $input[$filter->key()] !== '') {
$filter->apply($query, $input[$filter->key()]);
}
}
return $query;
}
}
Then bind a per-model filter set and call it from the controller. That's the whole idea, and it's ~50 lines.
Pattern 1 — Port the filter objects, keep the ergonomics. The bundle's value is the shape of the API (declarative filters, allowlisted keys, composable). Mirror it with one Filter class per concern rather than one giant switch:
final class StatusFilter implements Filter
{
public function key(): string { return 'status'; }
public function apply(Builder $q, mixed $v): void {
$q->whereIn('status', Arr::wrap($v));
}
}
Register them per-resource via a small factory or a FilterSet class, and resolve through the container so filters can take dependencies (e.g. a Closure table for permission-scoped filtering).
Pattern 2 — Translate Symfony-style config into Laravel config. If the bundle declares filters in YAML, mirror that in config/filters.php and resolve class names from it. This keeps the "declare, don't code" benefit while staying inside Laravel's config cache.
Pattern 3 — Query-string → FormRequest → FilterPipeline. Validate the keys, not just the values. Build the allowed key list from the registered filters so the two can never drift:
$allowed = collect($filters)->map->key()->all();
$input = $request->only($allowed);
return $pipeline->apply(Order::query(), $input)->paginate();
Pattern 4 — Anti-corruption layer. If you already run a Symfony service alongside Laravel, expose the filtered result as JSON from Symfony and consume it in Laravel. Do not try to share the bundle's container with Laravel's — the two DI models don't bridge cleanly.
Sorting and pagination: these almost always live alongside filtering. Implement them as separate, equally-small concerns (Sort, PerPage) with hardcoded allowlists of sortable columns. Never let a raw ?sort= value reach orderBy().
Integration tips:
app/Filtering/ and bind them with a #[Singleton]/service provider keyed by resource name, so app(FilterPipeline::class.':orders') is unnecessary — instead use a FilterRegistry that returns pipelines by key.php artisan make:filter stub early; it's the difference between a pattern and a convention.Doctrine\ORM\QueryBuilder, method names (andWhere, setParameter, expr()->in()) won't exist on Eloquent's Builder. You must rewrite the body of every filter even if you keep the interface.doctrine/annotations or docblock-based filter declarations. On PHP 8.2+ with modern Symfony, those are deprecated or removed — another reason not to run it directly.?password_like=...), and unvalidated values let them scan via LIKE. Always: allowlist keys, cast/validate values, and never interpolate column names from request input.LIKE filters need wildcard discipline. Escape % and _ in user input or a search for 100% will match everything.?customer.country=DE) silently turn into joins or lazy loads. Assert query count in tests with DB::listen or Laravel's QueryLog.?page=5&status=paid, changing status must reset page, or users get empty pages. Handle it in the request-normalisation step, not the view.status = 'active' when the parameter is absent). Make implicit defaults explicit and documented.Filter interface, a registry keyed by resource, and a single place where the allowlist is assembled. Everything else in the bundle is Doctrine glue you should discard.src/ for API inspiration; not worth a composer require in a Laravel app. The concept is sound and small — reimplement it, don't adopt it.How can I help you explore Laravel packages today?