Building queries
Build a query for a model (or a prepared builder), declare the allow-list, then treat the result like any Eloquent builder — unknown calls forward straight through:
use RoundlyConsulting\QueryBuilder\AllowedFilter;
use RoundlyConsulting\QueryBuilder\AllowedSort;
use RoundlyConsulting\QueryBuilder\QueryBuilder;
$posts = QueryBuilder::for(Post::class)
->allowedFilters(
AllowedFilter::exact('status'),
AllowedFilter::partial('title'),
AllowedFilter::scope('published', booleans: true),
AllowedFilter::callback('min_views', fn ($query, $value) => $query->where('views', '>=', $value)),
AllowedFilter::trashed(),
)
->allowedSorts('title', AllowedSort::field('popularity', 'views'))
->defaultSort('-created_at')
->with(['author'])
->paginate($request->perPage());Entry point
QueryBuilder::for(string|Builder $subject, ?Request $request = null) accepts a model class string or a prepared Eloquent builder, so you can pre-scope the query. The optional second argument overrides the request it reads from — it defaults to the current request():
// A model class — starts from Post::query()
QueryBuilder::for(Post::class);
// A prepared builder — its constraints are kept
QueryBuilder::for(Post::query()->where('active', true));
// An explicit request (tests, jobs, sub-requests) instead of request()
QueryBuilder::for(Post::class, $request);In a controller
A typical list endpoint pairs the builder with a FormRequest that validates the page size (see Pagination):
namespace App\Http\Controllers;
use App\Http\Requests\ListPostsRequest;
use App\Models\Post;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use RoundlyConsulting\QueryBuilder\AllowedFilter;
use RoundlyConsulting\QueryBuilder\QueryBuilder;
final class PostController
{
public function index(ListPostsRequest $request): LengthAwarePaginator
{
return QueryBuilder::for(Post::class)
->allowedFilters('status', AllowedFilter::partial('title'))
->allowedSorts('title', 'created_at')
->defaultSort('-created_at')
->with(['author'])
->paginate($request->perPage());
}
}A request against it:
GET /posts?filter[status]=published&filter[title]=laravel&sort=-created_at,title&page=2&per_page=25How it applies
- Shorthand — a bare string in allowedFilters() becomes AllowedFilter::exact(); in allowedSorts() it becomes AllowedSort::field().
- Lazy, once — filters and sorts are applied the first time a call is forwarded to the builder (get, paginate, with, where, …) or you call getEloquentBuilder(), and never twice.
- Transparent forwarding — a forwarded call that returns a builder returns the QueryBuilder itself, so chaining continues; anything else (a collection, a paginator, a model) is returned as is.
- getEloquentBuilder() — returns the underlying Eloquent builder with filters and sorts applied, for code that needs the real Builder instance.
$builder = QueryBuilder::for(Post::class)
->allowedFilters('status')
->allowedSorts('title')
->getEloquentBuilder(); // Illuminate\Database\Eloquent\Builder, filters + sorts applied
$count = $builder->count();Declare the allow-list first
Declare allowedFilters(), allowedSorts() and defaultSort() before any builder call. The request is applied the first time a call is forwarded to the builder, because that call may be the one that runs the query. A declaration after that point throws AllowListAlreadyApplied (a LogicException) instead of being silently ignored — whatever the request contains, so a misordered chain fails in development, not on the first client that filters:
QueryBuilder::for(Post::class)
->allowedFilters('status') // declare first…
->where('views', '>', 0) // …then any builder call
->get();
QueryBuilder::for(Post::class)
->where('views', '>', 0)
->allowedFilters('status'); // throws AllowListAlreadyApplied
// Constraints that must exist before the request is applied go on the builder you pass in
QueryBuilder::for(Post::query()->where('views', '>', 0));What it leaves to you
The package covers filtering, sorting and pagination only. Include and eager-load management, sparse fieldsets, appended attributes and cursor pagination are deliberately out of scope — the package never reads such parameters from the request. Eager-load in code with with() as usual.
Show your open-source love
This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.
More ways to support, including cryptoBy donating, you agree to our donation terms.
Want this built into your product?
We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.