spatie/laravel-query-builder
Build safe, flexible Eloquent queries from incoming API requests. Supports whitelisted filtering (partial/exact/scope/custom), sorting, includes, field selection, pagination, and grouped AND/OR filters—ideal for JSON:API-style endpoints with minimal boilerplate.
Installation:
composer require spatie/laravel-query-builder
Publish the config (optional):
php artisan vendor:publish --provider="Spatie\QueryBuilder\QueryBuilderServiceProvider"
First Use Case:
Filter a basic API endpoint for User models:
use Spatie\QueryBuilder\QueryBuilder;
Route::get('/users', function () {
return QueryBuilder::for(User::class)
->allowedFilters('name', 'email')
->get();
});
Test with:
GET /users?filter[name]=John
QueryBuilder Facade: Core class for building queries.AllowedFilter, AllowedSort, AllowedInclude: Classes for defining allowed operations.Define Allowed Operations:
QueryBuilder::for(User::class)
->allowedFilters('name', 'email')
->allowedSorts('name', 'created_at')
->allowedIncludes('posts', 'permissions')
->allowedFields('id', 'name', 'email');
Chain with Existing Queries:
$baseQuery = User::where('active', true);
QueryBuilder::for($baseQuery)
->allowedFilters('name')
->get();
API Route Integration:
Route::get('/users', function () {
return QueryBuilder::for(User::class)
->allowedFilters('name', 'role')
->paginate(15);
});
Grouped Filters (OR/AND Logic):
QueryBuilder::for(User::class)
->allowedFilters(
AllowedFilter::groupOr('search', [
AllowedFilter::partial('name'),
AllowedFilter::partial('email'),
])
)
->get();
Request: /users?filter[search]=John
Custom Sorting:
QueryBuilder::for(User::class)
->allowedSorts(
AllowedSort::custom('name-length', new StringLengthSort(), 'name')
)
->get();
Nested Includes:
QueryBuilder::for(User::class)
->allowedIncludes('posts.comments', 'permissions')
->get();
Field Selection:
QueryBuilder::for(User::class)
->allowedFields('id', 'name', 'email')
->get();
Request: /users?fields[users]=id,name
scout for search-as-you-type:
QueryBuilder::for(User::class)
->allowedFilters('name')
->scout()
->get();
QueryBuilder with Illuminate\Http\Resources\Json\JsonResource for consistent API responses.QueryBuilder in unit tests:
$builder = QueryBuilder::for(User::class);
$builder->shouldReceive('get')->andReturn(collect([new User()]));
Case Sensitivity in Filters:
partial) are case-insensitive by default. Use exact for case-sensitive matches:
->allowedFilters(AllowedFilter::exact('name'))
Default Values Override:
// GET /users?filter[name]=John&filter[active]=true
// Overrides default `active=true` if present.
Nested Includes and Scopes:
posts.comments) may fail if intermediate relationships lack proper constraints. Test thoroughly.Field Selection Conflicts:
allowedFields) can break eager-loaded relationships. Use with() for related data:
QueryBuilder::for(User::class)
->allowedFields('id', 'name')
->with('posts:id,title') // Explicitly load related fields
->get();
Custom Sort Direction:
AllowedSort::custom) is ASC. Explicitly set if needed:
AllowedSort::custom('name-length', new StringLengthSort(), 'name')
->defaultDirection(SortDirection::Descending)
QueryBuilder::for(User::class)
->allowedFilters('name')
->toSql(); // Log the generated SQL
ValidatesRequests to sanitize input before processing:
public function rules()
{
return [
'filter.name' => 'sometimes|string|max:255',
'sort' => 'sometimes|string',
];
}
InvalidFilterQuery, InvalidSortQuery, etc., for graceful API responses:
try {
return QueryBuilder::for(User::class)->get();
} catch (\Spatie\QueryBuilder\Exceptions\InvalidFilterQuery $e) {
return response()->json(['error' => $e->getMessage()], 400);
}
Custom Filter Logic:
Extend Spatie\QueryBuilder\Filters\Filter for reusable filters:
class AgeFilter extends Filter
{
protected $name = 'age';
protected $callback;
public function __construct()
{
$this->callback = function ($query, $value) {
return $query->where('age', '>=', $value);
};
}
}
Usage:
QueryBuilder::for(User::class)
->allowedFilters(new AgeFilter())
->get();
Middleware for Global Config:
Apply QueryBuilder settings globally via middleware:
public function handle($request, Closure $next)
{
QueryBuilder::setDefaultOperator('and');
return $next($request);
}
Dynamic Allowed Operations: Fetch allowed filters/sorts from a database table:
$allowedFilters = FilterConfig::where('model', User::class)->pluck('field');
QueryBuilder::for(User::class)
->allowedFilters($allowedFilters)
->get();
Testing Helpers: Create a trait for consistent test setups:
trait QueryBuilderTests
{
protected function buildQuery($model, array $allowed = [])
{
return QueryBuilder::for($model)
->allowedFilters($allowed)
->allowedSorts('id');
}
}
and/or) globally in config/query-builder.php:
'default_operator' => 'or',
QueryBuilder::for(User::class)
->ignoreMissingFilters()
->get();
QueryBuilder::for(User::class)
->paginate(20)
->appends(request()->except('page'));
How can I help you explore Laravel packages today?