If you have spent any time building Laravel APIs, you have almost certainly written a controller method that looks like this:
public function index(Request $request)
{
$query = Post::query();
if ($request->has('status')) {
$query->where('status', $request->status);
}
if ($request->has('author_id')) {
$query->where('author_id', $request->author_id);
}
if ($request->has('sort')) {
$query->orderBy($request->sort, $request->get('direction', 'asc'));
}
return PostResource::collection($query->paginate(20));
}
This works for two filters. At ten filters it becomes a maintenance problem. At twenty, it's a bug surface. Spatie Laravel Query Builder replaces that entire pattern with a declarative, tested, composable API that scales cleanly.
This article focuses narrowly on Spatie Query Builder — installation through production patterns. For the broader context of Laravel API architecture (versioning, Sanctum auth, rate limiting, N+1 prevention), see Building Production-Ready APIs with Laravel.
Prerequisites
- Laravel 11 or 12 (PHP 8.2 minimum)
-
spatie/laravel-query-builder^6.x (supports Laravel 11/12) - Composer installed
- Basic familiarity with Eloquent and API Resources
Install the package:
composer require spatie/laravel-query-builder
No service provider registration is required — the package auto-discovers itself.
The Core Concept
Spatie Query Builder wraps an Eloquent query and maps incoming HTTP query parameters to allowed filters, sorts, and includes. Only parameters you explicitly allow are applied. Everything else is silently ignored, which prevents query-injection style attacks where a client passes arbitrary column names.
The basic structure:
use Spatie\QueryBuilder\QueryBuilder;
use Spatie\QueryBuilder\AllowedFilter;
use Spatie\QueryBuilder\AllowedSort;
use Spatie\QueryBuilder\AllowedInclude;
$posts = QueryBuilder::for(Post::class)
->allowedFilters([...])
->allowedSorts([...])
->allowedIncludes([...])
->paginate(20);
The client controls the query via URL parameters:
GET /api/v1/posts?filter[status]=published&sort=-created_at&include=author
Filters in Depth
Exact Filters
The simplest filter matches a column exactly:
->allowedFilters(['status', 'author_id'])
A request to ?filter[status]=published translates to WHERE status = 'published'.
Partial (LIKE) Filters
For search-style filtering:
use Spatie\QueryBuilder\AllowedFilter;
->allowedFilters([
AllowedFilter::partial('title'),
])
?filter[title]=laravel becomes WHERE title LIKE '%laravel%'. Be aware this disables index usage on most database engines — add a full-text index or move to a search engine (Meilisearch, Typesense) if the table is large.
Scope Filters
Scope filters delegate the filtering logic to a named Eloquent scope on the model:
// Post model
public function scopePublishedAfter(Builder $query, string $date): Builder
{
return $query->where('published_at', '>=', $date);
}
// Controller
->allowedFilters([
AllowedFilter::scope('published_after'),
])
Request: ?filter[published_after]=2026-01-01
Scope filters are the cleanest way to encapsulate complex WHERE logic (date ranges, geographic bounding boxes, status machines) without polluting the controller.
Custom Filters
When you need full control — joining another table, conditional subqueries, or multi-column logic — implement \Spatie\QueryBuilder\Filters\Filter:
use Spatie\QueryBuilder\Filters\Filter;
use Illuminate\Database\Eloquent\Builder;
class TagFilter implements Filter
{
public function __invoke(Builder $query, mixed $value, string $property): void
{
$tags = is_array($value) ? $value : explode(',', $value);
$query->whereHas('tags', function (Builder $q) use ($tags) {
$q->whereIn('slug', $tags);
});
}
}
// Controller
->allowedFilters([
AllowedFilter::custom('tags', new TagFilter()),
])
Request: ?filter[tags]=php,laravel
Filter Defaults
You can supply a default value so that the filter applies even when the client does not pass it:
AllowedFilter::exact('status')->default('published')
This is useful for endpoints that should only surface active records by default, with an opt-in to see all records for admin consumers.
Sorting
Allowed sorts map request parameter values to column names:
->allowedSorts([
'title',
'created_at',
AllowedSort::field('newest', 'created_at'),
])
The client prefixes a column name with - for descending order:
?sort=-created_at → ORDER BY created_at DESC
?sort=title → ORDER BY title ASC
?sort=newest → ORDER BY created_at ASC (aliased)
Set a default sort so paginated responses are stable without a client-supplied sort:
->defaultSort('-created_at')
Without a default sort, paginate() on a large dataset produces non-deterministic page boundaries — records can appear on multiple pages or be skipped entirely as rows are inserted.
Eager-Loading Includes
Allowed includes let the client opt into relationship loading:
->allowedIncludes(['author', 'tags', 'comments.author'])
Request: ?include=author,tags
Behind the scenes this calls with(['author', 'tags']) on the query. Because the relationships are eager-loaded before the Resource layer runs, whenLoaded() in your API Resource works correctly:
// PostResource.php
public function toArray($request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'author' => new UserResource($this->whenLoaded('author')),
'tags' => TagResource::collection($this->whenLoaded('tags')),
];
}
When author is not included, whenLoaded('author') returns MissingValue and the key is omitted from the response. No N+1. No accidental data exposure.
A Full Controller Example
namespace App\Http\Controllers\Api\V1;
use App\Http\Resources\PostResource;
use App\Models\Post;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Spatie\QueryBuilder\AllowedFilter;
use Spatie\QueryBuilder\AllowedSort;
use Spatie\QueryBuilder\QueryBuilder;
class PostController
{
public function index(): AnonymousResourceCollection
{
$posts = QueryBuilder::for(Post::class)
->allowedFilters([
AllowedFilter::exact('status')->default('published'),
AllowedFilter::exact('author_id'),
AllowedFilter::partial('title'),
AllowedFilter::scope('published_after'),
AllowedFilter::custom('tags', new TagFilter()),
])
->allowedSorts([
'title',
AllowedSort::field('newest', 'created_at'),
])
->defaultSort('-created_at')
->allowedIncludes(['author', 'tags'])
->paginate(request()->integer('per_page', 20));
return PostResource::collection($posts);
}
}
This replaces 50+ lines of imperative if-blocks with a structure that is readable, testable in isolation, and extendable by adding a single line.
Common Mistakes
1. Using AllowedFilter::partial() on unindexed columns
LIKE queries with a leading wildcard (%term%) cannot use standard B-Tree indexes. For any table above a few thousand rows, either add a full-text index, limit the search to a prefix pattern (term%), or offload to a dedicated search service.
2. Allowing includes without thinking about depth
Nested includes like comments.author.profile can generate deep join trees. Audit every allowed include path — or cap depth using the package's allowedIncludes list and never use wildcards.
3. Omitting defaultSort()
Pagination without a stable sort order is broken by definition. Always set a default sort, ideally on an indexed column.
4. Filtering on non-guarded columns in custom filters
Custom Filter implementations receive the raw client-supplied value. Always validate or cast inside the implementation. Do not interpolate $value into raw SQL strings.
5. Not wrapping the QueryBuilder in a FormRequest
QueryBuilder handles the query-parameter side. It does not validate that filter[author_id] is an integer or that filter[published_after] is a valid date. Add a FormRequest alongside the QueryBuilder for input validation:
public function index(IndexPostRequest $request): AnonymousResourceCollection
{
// $request->validated() runs before QueryBuilder reads the parameters
...
}
Limitations and Tradeoffs
Spatie Query Builder works on Eloquent queries. It does not integrate with raw DB::select() calls or Eloquent queries that have already been executed. If your endpoint returns data from a stored procedure or a complex multi-union query, you will need to handle filtering manually.
The filter[] bracket syntax is conventional for this library and matches PHP's native query-string parsing. However, some API clients and gateways (AWS API Gateway URL validation, certain proxies) require additional configuration to allow bracket characters in query parameters. Test your infrastructure with this syntax early.
Sorting by computed or aggregate columns (e.g., sort=comments_count) requires that the column is already selected or appended via withCount() on the base query before handing control to QueryBuilder. Use AllowedSort::field() to alias the aggregate:
$posts = QueryBuilder::for(
Post::withCount('comments') // must be in the base query
)
->allowedSorts([
AllowedSort::field('popularity', 'comments_count'),
])
->paginate(20);
Testing Filtered Endpoints
Feature tests against filtered endpoints are straightforward with Laravel's test helpers:
use Illuminate\Foundation\Testing\RefreshDatabase;
test('filters posts by status', function () {
Post::factory()->create(['status' => 'published']);
Post::factory()->create(['status' => 'draft']);
$response = $this->actingAs($this->user, 'sanctum')
->getJson('/api/v1/posts?filter[status]=draft');
$response->assertOk()
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.status', 'draft');
});
test('ignores unknown filters', function () {
Post::factory()->count(3)->create(['status' => 'published']);
// 'unknown_column' is not in allowedFilters — should be ignored, not error
$response = $this->actingAs($this->user, 'sanctum')
->getJson('/api/v1/posts?filter[unknown_column]=value');
$response->assertOk()->assertJsonCount(3, 'data');
});
By default Spatie Query Builder ignores disallowed filters silently. If you want it to throw a 422 when an unknown filter is passed (useful for strict API clients), set 'throw_invalid_query_exceptions' => true in the config/query-builder.php file (publish it first with php artisan vendor:publish --provider="Spatie\QueryBuilder\QueryBuilderServiceProvider").
Summary
Spatie Query Builder solves a real, recurring Laravel API problem — complex, hand-rolled filtering logic — through a clean declarative interface. The key patterns:
- Use exact and scope filters for most cases; reserve custom filters for complex multi-table logic
- Always set a default sort on an indexed column
- Combine
allowedIncludeswithwhenLoaded()in API Resources to avoid N+1 queries - Validate raw input in a FormRequest — QueryBuilder applies filters but does not type-check them
- Publish the config and enable
throw_invalid_query_exceptionsin strict consumer environments
If you need Laravel development in Mumbai, Mumbai Web Designer builds production-grade Laravel applications.
Top comments (0)