Clean, Powerful API Filtering with Spatie Query Builder
Build flexible, readable, and secure query filters across multiple Laravel models — no more bloated controllers.
Why Spatie Query Builder?
If you’ve built a Laravel API, you’ve almost certainly written something like this in a controller:
$query = Product::query();
if ($request->filled('status')) {
$query->where('status', $request->status);
}
if ($request->filled('name')) {
$query->where('name', 'LIKE', "%{$request->name}%");
}
if ($request->filled('sort')) {
$query->orderBy($request->sort); // SQL injection risk!
}
return $query->paginate();
It works, but it grows fast. New filters mean more if blocks. Sorting is unsafe without a whitelist. Relations add even more complexity. Controllers end up doing far too much.
Spatie’s laravel-query-builder package solves this cleanly. It reads filter parameters directly from the request URL and maps them to declarative, whitelist-only query logic — keeping your controllers thin and your API consistent.
Every filter, sort, and include is opt-in. A user can’t filter or sort by a column you haven’t explicitly allowed. No security surprises.
Installation & Setup
Install the package via Composer. It works out of the box with Laravel 12 — no extra configuration needed.
composer require spatie/laravel-query-builder
Optionally publish the config if you want to customise defaults like the filter parameter name:
php artisan vendor:publish --provider="Spatie\QueryBuilder\QueryBuilderServiceProvider"
The Models
This walkthrough uses three related models to showcase filters across a realistic e-commerce domain: Category, Product, and Order. Full migrations and model code are in the GitHub repository. The key relationships are:
- A
Categoryhas manyProducts - A
Productbelongs to aCategory - An
Orderbelongs to aUser
Built-in Filter Types
Spatie ships with several filter strategies that cover the majority of real-world needs. Here’s a reference:
Some built in methods and behavior
| Method | Behavior | Best for |
| AllowedFilter::exact() | Strict WHERE col = value | status, IDs, booleans |
| AllowedFilter::partial() | WHERE col LIKE %value% | Name / text search |
| AllowedFilter::scope() | Delegates to a model local scope | Reusable query scopes |
| AllowedFilter::callback() | Inline closure for one-off logic | Simple custom conditions |
| AllowedFilter::custom() | Dedicated class implementing Filter | Complex, reusable filters |
Writing Custom Filters
When built-in types aren’t enough, you implement the Spatie\QueryBuilder\Filters\Filter interface. The class has a single __invoke method that receives the Eloquent builder, the filter value, and the property name.
Here are the three custom filters from the project — each solving a different common API problem.
Boolean scope filter:
Maps a truthy URL value to a stock check query:
//app/QueryFilters/InStockFilter.php
class InStockFilter implements Filter
{
public function __invoke(Builder $query, mixed $value, string $property): void
{
if (filter_var($value, FILTER_VALIDATE_BOOLEAN)) {
$query->where('stock', '>', 0);
} else {
$query->where('stock', '=', 0);
}
}
}
2. Price range filter
Accepts a single min,max string, making the URL compact and readable:
//app/QueryFilters/PriceRangeFilter.php
class PriceRangeFilter implements Filter
{
public function __invoke(Builder $query, mixed $value, string $property): void
{
// "100,500" → price BETWEEN 100 AND 500
[$min, $max] = array_pad(explode(',', $value, 2), 2, null);
if ($min !== null) $query->where('price', '>=', (float) $min);
if ($max !== null) $query->where('price', '<=', (float) $max);
}
}
3. Reusable date range filter
The constructor accepts the column name, making this one class work across any date column:
// app/QueryFilters/DateRangeFilter.php
class DateRangeFilter implements Filter
{
public function __construct(private string $column = 'created_at') {}
public function __invoke(Builder $query, mixed $value, string $property): void
{
[$from, $to] = array_pad(explode(',', $value, 2), 2, null);
if ($from) $query->whereDate($this->column, '>=', $from);
if ($to) $query->whereDate($this->column, '<=', $to);
}
}
Custom filter classes are fully reusable. Pass
new DateRangeFilter('ordered_at')on Orders, ornew DateRangeFilter('shipped_at')on Shipments — same class, different column.Controller Setup
Here’s how the
ProductControllerlooks with all filter types composed together. Notice how the controller stays thin — it just declares what’s allowed, and the package handles the rest.//app/Http/Controllers/ProductController.php public function index(): JsonResponse { $products = QueryBuilder::for(Product::class) ->allowedFilters([ AllowedFilter::exact('status'), AllowedFilter::exact('is_featured'), AllowedFilter::exact('category_id'), AllowedFilter::partial('name'), // Filter on relation column using an alias AllowedFilter::exact('category', 'category.slug'), // Custom filters AllowedFilter::custom('in_stock', new InStockFilter()), AllowedFilter::custom('price_range', new PriceRangeFilter()), ]) ->allowedSorts(['name', 'price', 'stock', 'created_at']) ->allowedIncludes(['category']) ->defaultSort('-created_at') ->paginate(request('per_page', 15)) ->appends(request()->query()); return response()->json($products); }
The OrderController demonstrates the inline callback approach for simple one-off conditions where a full class would be overkill:
// app/Http/Controllers/OrderController.php
AllowedFilter::callback('min_total', function ($query, $value) {
$query->where('total', '>=', (float) $value);
}),
AllowedFilter::custom('date_range', new DateRangeFilter('ordered_at')),
Sorting & Relations
Sorting is whitelist-driven — users pass ?sort=price for ascending or ?sort=-price for descending. The leading - is a Spatie convention that maps cleanly to ORDER BY price DESC.
Including related models is just as declarative. Adding ?include=category to a products request eager-loads the relationship — but only if 'category' is listed in allowedIncludes().
Real API Examples:
Here’s what your API URLs look like in practice — clean, composable, and entirely driven by the whitelist you define:
GET /api/products?filter[status]=active&include=category
Active products with category eager-loaded
GET /api/products?filter[price_range]=100,500&sort=price
Price between $100–$500, sorted ascending
GET /api/products?filter[in_stock]=true&filter[is_featured]=true
In-stock featured items only
GET /api/products?filter[category]=electronics&sort=-created_at
Filter by category slug, newest first
GET /api/orders?filter[date_range]=2024-01-01,2024-06-30&sort=-total
H1 orders, highest value first
GET /api/orders?filter[status]=delivered&filter[min_total]=200
Delivered orders over $200
GET /api/categories?filter[has_active_products]=true&include=products
Categories with live products
Wrapping up:
Spatie Query Builder brings a declarative, secure, and consistent pattern to API filtering in Laravel. Instead of growing a tangle of if statements, you maintain a clear allow-list and a small library of composable filter classes.
The three custom filters in this project — InStockFilter, PriceRangeFilter, and DateRangeFilter — demonstrate how easy it is to handle real-world edge cases without leaking logic into the controller layer.
The full source code with migrations, seeders, and all three models is available in the GitHub repository linked at the top of this post. Clone it, run the migrations, and you’ll have a working filterable API in minutes.
