l3aro/pipeline-query-collection
A Laravel package for building Eloquent queries with pipelines: chain reusable filter classes into a clean, testable sequence, so complex query logic stays out of controllers and models and remains easy to compose, reuse, and maintain.
composer require l3aro/pipeline-query-collection. v1.6.0 widens the framework constraint to include Laravel 13.x, so the same install works on Laravel 10–13 (check composer.json for the exact PHP floor before bumping).tests/ directory — the tests are the most reliable documentation of the run order and how request input is threaded through.index() action — inline when() filters, a sort switch, a search block — and split it into a collection class plus one small query class per concern. That is the entire value proposition: the controller names what queries run, each query class owns how.// app/Queries/Users/UserCollection.php
class UserCollection extends QueryCollection
{
protected array $queries = [
SearchUsers::class,
FilterByStatus::class,
SortUsers::class,
];
}
// Controller
return UserResource::collection(
UserCollection::make(User::query(), $request->validated())->paginate()
);
(Exact constructor/factory signature varies by version — confirm against the README before copying; the shape above is the pattern, not a guaranteed API.)
One query class per axis of variation. Resist FilterUsers that handles status, role, and date range. Separate classes stay individually testable and can be recombined across collections (an admin collection and a public collection can share SearchUsers but not FilterByDeleted).
Feed it validated input, not the raw request. Pass $request->validated() (or $request->only([...])) from a FormRequest. The collection then never has to guess whether ?status= is user-supplied garbage, and your filters collapse to simple filled() checks.
class FilterByStatus implements QueryContract
{
public function apply(Builder $query, array $input): Builder
{
return $query->when(
filled($input['status'] ?? null),
fn (Builder $q) => $q->whereIn('status', Arr::wrap($input['status']))
);
}
}
Apply scoping/authorization first. Put a ScopeToTenant or ScopeToUser query at the head of the list so downstream filters can only narrow an already-safe result set. Never rely on a filter class to enforce access control — a missing query parameter would silently widen it.
Sort whitelisting belongs in the sort query. Map request values to real columns through an explicit array; never interpolate orderBy($input['sort']). This is the single most common place these packages get abused.
Compose collections rather than branching inside them. A BaseUserCollection with search + sort, then AdminUserCollection extends BaseUserCollection appending FilterByDeleted and IncludeTrashed. Keeps each endpoint's behavior readable in one short array.
Test at both levels. Unit-test a single query class against a Builder and assert the compiled SQL or result set; feature-test the collection end-to-end with a handful of query-string permutations. The unit tests are where this architecture pays off — they replace most of your HTTP-level filter tests.
Use it for internal/API listing endpoints, not for one-off queries. If a listing has one filter and no growth expected, a when() is cheaper than a class. The pipeline earns its keep at ~3+ composable concerns or when the same concerns appear on several endpoints.
limit()/paginate() placed before orderBy gives you an arbitrary page. Put pagination at the call site, not in a query class.$this in the constructor; if the collection instance is reused (or the class is resolved as a singleton in a container binding), you'll leak state between calls and get bizarre cross-request bugs that only show up under Octane.with() inside a filter class runs before later filters — usually fine, occasionally wasteful. Prefer a dedicated EagerLoadRelations query placed last, or keep with() at the call site so it's visible in the controller.paginate() + distinct()/groupBy() is a known Eloquent trap, not a package one. If a filter introduces a join, verify the count query — check with ->toSql() on both the rows and count queries.ray()->sql() / logger($query->toSql(), $query->getBindings()) inside the pipeline loop. Logging which query classes ran alongside the input keys makes "why did this filter not apply" a 10-second question instead of a 10-minute one.?status=0, ?min_price=0, and empty strings are the usual culprits — use filled()/array_key_exists rather than if ($value), or 0 filters silently no-op."^1.6") rather than *. The package is pre-2.0, so minor releases are the de-facto breaking-change channel; review each changelog before a composer update in CI.How can I help you explore Laravel packages today?