Build filtered, sorted, and included API endpoints using spatie/laravel-query-builder. Activates when working with QueryBuilder, AllowedFilter, AllowedSort, AllowedInclude, or when the user mentions query parameters, API filtering, sorting, includes, or spatie/laravel-query-builder.
Activate this skill when:
use Spatie\QueryBuilder\QueryBuilder;
use Spatie\QueryBuilder\AllowedFilter;
use Spatie\QueryBuilder\AllowedInclude;
use Spatie\QueryBuilder\AllowedSort;
// GET /users?filter[name]=John&sort=-created_at&include=posts
$users = QueryBuilder::for(User::class)
->allowedFilters('name', 'email')
->allowedSorts('name', 'created_at')
->allowedIncludes('posts', 'permissions')
->allowedFields('id', 'name', 'email')
->get();
use Spatie\QueryBuilder\AllowedFilter;
QueryBuilder::for(User::class)
->allowedFilters(
AllowedFilter::partial('name'), // WHERE name LIKE '%value%' (default)
AllowedFilter::exact('email'), // WHERE email = 'value'
AllowedFilter::beginsWith('name'), // WHERE name LIKE 'value%'
AllowedFilter::endsWith('name'), // WHERE name LIKE '%value'
AllowedFilter::scope('active'), // Calls scopeActive()
AllowedFilter::callback('search', fn ($query, $value) => ...),
AllowedFilter::exact('role')->default('user'), // Default filter value
AllowedFilter::exact('status')->nullable(), // Allows null values
AllowedFilter::exact('role')->ignore('admin'), // Ignores specific values
AllowedFilter::belongsTo('author'), // Filter by BelongsTo relationship
AllowedFilter::trashed(), // Filter soft deletes (with, only, without)
);
use Spatie\QueryBuilder\AllowedFilter;
use Spatie\QueryBuilder\Enums\FilterOperator;
QueryBuilder::for(User::class)
->allowedFilters(
AllowedFilter::operator('salary', FilterOperator::GreaterThan),
AllowedFilter::operator('age', FilterOperator::LessThanOrEqual),
AllowedFilter::operator('salary', FilterOperator::Dynamic), // Operator from request
);
// GET /users?filter[salary]=gt:50000 (with Dynamic operator)
Filter by related model properties using dot notation:
// GET /users?filter[posts.title]=Laravel
QueryBuilder::for(User::class)
->allowedFilters(AllowedFilter::partial('posts.title'));
// GET /users?filter[email]=john
// Queries the 'user_email' column
AllowedFilter::exact('email', 'user_email');
Implement Spatie\QueryBuilder\Filters\Filter:
use Spatie\QueryBuilder\Filters\Filter;
use Illuminate\Database\Eloquent\Builder;
class FiltersUserPermission implements Filter
{
public function __invoke(Builder $query, mixed $value, string $property): void
{
$query->whereHas('permissions', fn ($q) => $q->where('name', $value));
}
}
AllowedFilter::custom('permission', new FiltersUserPermission());
use Spatie\QueryBuilder\AllowedSort;
use Spatie\QueryBuilder\Enums\SortDirection;
QueryBuilder::for(User::class)
->allowedSorts(
'name',
'created_at',
AllowedSort::field('order', 'sort_order'), // Alias: ?sort=order queries sort_order
AllowedSort::custom('popular', new SortMostPopular()),
AllowedSort::callback('random', fn ($query, $descending) => $query->inRandomOrder()),
)
->defaultSort('name')
->defaultSorts('name', AllowedSort::field('date', 'created_at')->defaultDirection(SortDirection::Descending));
// GET /users?sort=-created_at,name (descending created_at, ascending name)
Implement Spatie\QueryBuilder\Sorts\Sort:
use Spatie\QueryBuilder\Sorts\Sort;
use Illuminate\Database\Eloquent\Builder;
use Spatie\QueryBuilder\Enums\SortDirection;
class SortMostPopular implements Sort
{
public function __invoke(Builder $query, SortDirection $direction, string $property): void
{
$query->withCount('followers')->orderBy('followers_count', $direction->value);
}
}
use Spatie\QueryBuilder\AllowedInclude;
QueryBuilder::for(User::class)
->allowedIncludes(
'posts', // Eager loads posts (also allows postsCount and postsExists)
'posts.comments', // Nested eager loading
AllowedInclude::count('commentsCount'), // Only withCount, no full relation
AllowedInclude::exists('postsExists'), // Only withExists
AllowedInclude::relationship('profile', 'userProfile'), // Alias
AllowedInclude::callback('latestPost', fn ($query) => $query->latestOfMany()),
);
// GET /users?include=posts,commentsCount,postsExists
AllowedInclude::min('postsViewsMin', 'posts', 'views'); // withMin('posts', 'views')
AllowedInclude::max('postsViewsMax', 'posts', 'views'); // withMax('posts', 'views')
AllowedInclude::sum('postsViewsSum', 'posts', 'views'); // withSum('posts', 'views')
AllowedInclude::avg('postsViewsAvg', 'posts', 'views'); // withAvg('posts', 'views')
// GET /users?include=postsViewsSum,postsViewsAvg
Implement Spatie\QueryBuilder\Includes\IncludeInterface:
use Spatie\QueryBuilder\Includes\IncludeInterface;
use Illuminate\Database\Eloquent\Builder;
class IncludeLatestPost implements IncludeInterface
{
public function __invoke(Builder $query, string $include): void
{
$query->with(['latestPost' => fn ($q) => $q->latest()]);
}
}
AllowedInclude::custom('latestPost', new IncludeLatestPost());
// GET /users?fields[users]=id,name&fields[posts]=id,title&include=posts
QueryBuilder::for(User::class)
->allowedFields('id', 'name', 'email')
->allowedIncludes('posts')
->get();
Published to config/query-builder.php:
return [
// Custom query parameter names
'parameters' => [
'include' => 'include',
'filter' => 'filter',
'sort' => 'sort',
'fields' => 'fields',
],
// Array value delimiter
'delimiter' => ',',
// Include suffixes (for count, exists, and aggregate includes)
'suffixes' => [
'count' => 'Count',
'exists' => 'Exists',
'min' => 'Min',
'max' => 'Max',
'sum' => 'Sum',
'avg' => 'Avg',
],
// Disable exception throwing for invalid queries
'disable_invalid_filter_query_exception' => false,
'disable_invalid_sort_query_exception' => false,
'disable_invalid_include_query_exception' => false,
];
Allow any requested filter, sort, or include without explicit listing. Restricted to local and testing environments:
QueryBuilder::for(User::class)
->allowedFilters('*')
->allowedSorts('*')
->allowedIncludes('*');
// From an Eloquent query
QueryBuilder::for(User::where('active', true))
->allowedFilters('name')
->get();
// From a relation
QueryBuilder::for($team->users())
->allowedFilters('name')
->get();
class UsersController
{
public function index()
{
$users = QueryBuilder::for(User::class)
->allowedFilters(
AllowedFilter::partial('name'),
AllowedFilter::exact('email'),
AllowedFilter::scope('active'),
)
->allowedSorts('name', 'created_at')
->allowedIncludes('posts', 'permissions')
->allowedFields('id', 'name', 'email')
->defaultSort('name')
->paginate();
return UserResource::collection($users);
}
}
$filters = ['name', 'email'];
$sorts = ['name', 'created_at'];
QueryBuilder::for(User::class)
->allowedFilters(...$filters)
->allowedSorts(...$sorts);
allowedIncludes() to eager load relationships instead of accessing them in views/resources without loadingAllowedFilter::exact() for boolean/integer columns, or handle casting in custom filtersposts.commentsallowedFields() with allowedIncludes(), make sure to include the foreign key columns needed for the relationships