DEV Community

Cover image for Filtering Laravel APIs with Spatie Query Builder
Sumeet Shroff
Sumeet Shroff

Posted on

Filtering Laravel APIs with Spatie Query Builder

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));
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

The client controls the query via URL parameters:

GET /api/v1/posts?filter[status]=published&sort=-created_at&include=author
Enter fullscreen mode Exit fullscreen mode

Filters in Depth

Exact Filters

The simplest filter matches a column exactly:

->allowedFilters(['status', 'author_id'])
Enter fullscreen mode Exit fullscreen mode

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'),
])
Enter fullscreen mode Exit fullscreen mode

?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'),
])
Enter fullscreen mode Exit fullscreen mode

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()),
])
Enter fullscreen mode Exit fullscreen mode

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')
Enter fullscreen mode Exit fullscreen mode

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'),
])
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Set a default sort so paginated responses are stable without a client-supplied sort:

->defaultSort('-created_at')
Enter fullscreen mode Exit fullscreen mode

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'])
Enter fullscreen mode Exit fullscreen mode

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')),
    ];
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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
    ...
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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');
});
Enter fullscreen mode Exit fullscreen mode

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 allowedIncludes with whenLoaded() 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_exceptions in strict consumer environments

If you need Laravel development in Mumbai, Mumbai Web Designer builds production-grade Laravel applications.

Top comments (0)