spatie/laravel-query-builder
Safely build Eloquent queries from incoming API requests. Allowlist filters, sorts, includes, and fields; supports partial/exact and custom filters, nested relationships, relation counts, and default values. Works with existing queries for clean, consistent endpoints.
Installation:
composer require spatie/laravel-query-builder
Publish the config (optional):
php artisan vendor:publish --provider="Spatie\QueryBuilder\QueryBuilderServiceProvider"
First Use Case:
use Spatie\QueryBuilder\QueryBuilder;
// Basic filtering
$users = QueryBuilder::for(\App\Models\User::class)
->allowedFilters('name', 'email')
->get();
Access via API: /users?filter[name]=John
Where to Look First:
config/query-builder.php for configuration options.API Resource Filtering:
// In a controller
public function index(Request $request)
{
$users = QueryBuilder::for(\App\Models\User::class)
->allowedFilters([
'name', // partial match by default
AllowedFilter::exact('email'),
AllowedFilter::operator('age', FilterOperator::GREATER_THAN),
])
->allowedSorts('name', 'created_at')
->allowedFields('id', 'name', 'email')
->get();
return UserResource::collection($users);
}
API Request: /users?filter[name]=John&filter[age]=30&sort=-created_at&fields[users]=id,name
Integration with Existing Queries:
$baseQuery = \App\Models\User::where('active', true);
$users = QueryBuilder::for($baseQuery)
->allowedIncludes('posts')
->get();
Dynamic Filtering in Middleware:
public function handle(Request $request, Closure $next)
{
$request->merge([
'filter[active]' => 'true',
]);
return $next($request);
}
Reusable Query Builder Classes:
class UserQueryBuilder extends QueryBuilder
{
public function __construct()
{
parent::__construct(\App\Models\User::class);
$this->allowedFilters([
'name',
AllowedFilter::exact('id'),
AllowedFilter::scope('is_admin'),
]);
$this->allowedSorts('name', 'created_at');
}
}
// Usage
$users = (new UserQueryBuilder())->get();
Custom Filter Logic:
QueryBuilder::for(\App\Models\Post::class)
->allowedFilters([
AllowedFilter::custom('published', function ($query, $value) {
return $query->where('published_at', '<=', $value)
->orWhere('published_at', null);
}),
])
->get();
Nested Relationship Includes:
QueryBuilder::for(\App\Models\User::class)
->allowedIncludes('posts.author')
->get();
API Request: /users?include=posts.author
Conditional Query Building:
$query = QueryBuilder::for(\App\Models\User::class);
if ($request->has('advanced')) {
$query->allowedFilters([
AllowedFilter::belongsTo('role'),
AllowedFilter::operator('score', FilterOperator::DYNAMIC),
]);
}
$users = $query->get();
Pagination and API Resources:
use Spatie\QueryBuilder\QueryBuilder;
use Illuminate\Pagination\LengthAwarePaginator;
$users = QueryBuilder::for(\App\Models\User::class)
->allowedFilters('name')
->paginate(15)
->appends(request()->query());
return new UserCollection($users);
Filter Validation:
InvalidFilterQuery (unless disable_invalid_filter_query_exception is true in config).->allowedFilters([]) // Explicitly disallow all filters
Case Sensitivity in Partial Filters:
LIKE LOWER(%value%) by default, which may not match case-sensitive databases.AllowedFilter::partial('name', null, false, true) to preserve case sensitivity.Performance with LIKE Queries:
LIKE) can ignore database indexes.beginsWith or endsWith for indexed columns:
->allowedFilters(AllowedFilter::beginsWith('email'))
Relationship Constraints:
posts.title) may not work as expected with complex joins.AllowedFilter::exact('posts.title', null, false)
Scope Parameter Handling:
// Correct: filter[tags]=tag1,tag2
// Incorrect: filter[tags]=tag1%2Ctag2 (may cause issues)
Caching Queries:
$cacheKey = 'users_'.md5(request()->getQueryString());
return Cache::remember($cacheKey, now()->addMinutes(5), function () {
return QueryBuilder::for(\App\Models\User::class)->allowedFilters('name')->get();
});
Inspect the Final Query:
toSql() to debug:
$query = QueryBuilder::for(\App\Models\User::class)->allowedFilters('name')->getQuery();
\Log::info($query->toSql(), $query->getBindings());
Validate Request Input:
\Log::info('Request query:', request()->query());
Test Edge Cases:
// Test with empty filter
$this->get('/users?filter[name]=')->assertOk();
Check for Typos:
namee instead of name) will silently fail unless disable_invalid_filter_query_exception is false.disable_invalid_filter_query_exception:
true hides invalid filter errors but does not allow arbitrary filters. Useful for APIs where you want to ignore unknown filters gracefully.Default Values:
'default_filter_values' => [
'active' => 'true',
],
->defaultFilterValues(['active' => 'false'])
Global Allowed Filters:
'global' => [
'allowed_filters' => ['name', 'email'],
'allowed_sorts' => ['name', 'created_at'],
],
Custom Filter Classes:
Spatie\QueryBuilder\Filters\Filter to create reusable filters:
class PublishedAtFilter extends Filter
{
protected $name = 'published_at';
public function apply($query, $value)
{
return $query->where('published_at', '<=', $value);
}
}
allowedFilters:
->allowedFilters(new PublishedAtFilter())
Middleware for QueryBuilder:
public function handle($request, Closure $next)
{
if ($request->expectsJson()) {
$response = $next($request);
return $response;
}
$query = QueryBuilder::for(\App\Models\User::class)
->allowedFilters('name')
->allowedSorts('name');
// Replace the route's controller logic with QueryBuilder
return response()->json($query->get());
}
Dynamic Model Binding:
$modelClass = \request('model', \App\Models\User::class);
$query = QueryBuilder::
How can I help you explore Laravel packages today?