
Every Laravel API reaches the same fork in the road. A frontend asks for "just add a filter by status," then "and sort by created date," then "and can we also filter by price range and eager-load the customer?", and three sprints later your index() controller is a 120-line pile of if ($request->has(...)) branches that nobody wants to touch.
We hit that wall repeatedly. Across our own production Laravel apps at Redberry, payment platforms, HR tooling, real-estate and marketplace backends, the endpoint that lists things with filters is the one that rots fastest. The fix that stuck, in codebase after codebase, was Spatie Query Builder: it turns request query strings into safe, whitelisted Eloquent queries and deletes the branching entirely.
This guide is the version we wish we'd had: how to build a filterable, sortable, paginated endpoint end to end with the current v7 API, plus the two production gotchas, a security hole and an N+1 trap, that the quick tutorials skip.
What is Spatie Query Builder, and when should you use it?
spatie/laravel-query-builder is a package that reads filtering, sorting, field-selection and relationship-include instructions from the request query string and applies them to an Eloquent query, but only for the parameters you explicitly allow. The QueryBuilder class extends Laravel's Eloquent builder, so every scope, macro and method you already use still chains normally.
It follows the JSON:API conventions, so requests look like:
GET /api/apartments?filter[city]=Tbilisi&sort=-price&include=owner&fields[apartments]=id,title,price
Reach for it when you're exposing list/index endpoints that a frontend or third party will slice and dice: search results, admin tables, marketplace listings, reporting feeds. If you have a single fixed query with no user-controlled filtering, you don't need it; plain Eloquent is fine.
Install it:
composer require spatie/laravel-query-builder
Optionally publish the config to tune parameter names and behaviour:
php artisan vendor:publish --provider="Spatie\QueryBuilder\QueryBuilderServiceProvider" --tag="query-builder-config"
Spatie Query Builder: Key Features

Filtering
In one of our projects, we had a growing list of apartments and needed a straightforward way to filter and retrieve specific data based on user preferences. Spatie Laravel Query Builder made this process extremely easy for us.
As we worked on the “Apartment” model, our goal was to filter apartments by their name, price, and location. The initial option was to handle it like this in the traditional way:
$apartments = Apartment::when(request('name'), function ($query, $name) {
return $query->where('name', $name);
})
->when(request('price'), function ($query, $price) {
return $query->where('price', $price);
})
->when(request('location'), function ($query, $location) {
return $query->where('location', $location);
})
->get();
However, by using the Spatie Query Builder package, the code became way more concise and readable:
// apartments?filter[name]=myapartment
$filteredApartments = QueryBuilder::for(Apartment::class)
->allowedFilters([AllowedFilter::exact('name')])
->get();
// apartments with the exact name “myapartment”
Pretty straightforward, isn’t it?
Other than exact filters, we defined filters for non-exact matches. To filter apartments that contain the given value in their name (“myapartment” in this case), we simply defined filters like this:
$filteredApartments = QueryBuilder::for(Apartment::class)
->allowedFilters(['name'])
->get();
In addition to exact filters, Spatie Laravel Query Builder allowed us to create filters for partial matches, which gave us more flexibility in filtering apartments based on their contained values.
Scope Filters
Spatie Laravel Query Builder includes another great feature – scope filters. This feature allowed us to define custom query scopes on our models, extending the filtering capabilities beyond the default functionality.
Let’s explore how we defined a scope that allowed us to filter apartments where the price was greater than or equal to the specified value.
// Apartment.php
public function scopePriceFrom(Builder $query, $price): Builder
{
return $query->where(price, '>=', $price);
}
Then we executed the query and got the desired results – /apartments?filter[price_from]=5000
QueryBuilder::for(Apartment::class)
->allowedFilters([
AllowedFilter::scope('price_from'),
])
->get();
// apartments that cost more than or exactly 5000
Callback Filters
Callback Filter was another great feature of the package that allowed us to define custom filters.
This is how we handled the scenario when we needed to extract only those apartments that had at least one balcony:
QueryBuilder::for(Apartment::class)
->allowedFilters([
AllowedFilter::callback('has_balcony', function (Builder $query, $value) {
$query->whereHas('balconies');
}),
]);
But there’s more beyond Callback Filters. We took it a step further by creating invokable custom filter classes, where we had the flexibility to design our filters with precision. We simply implemented the \\Spatie\\QueryBuilder\\Filters\\Filter interface, and the __invoke method received the current query builder instance along with the filter name/value.
class FiltersApartmentRooms implements Filter
{
public function __invoke(Builder $query, $value, string $property)
{
$query->whereHas(rooms, function (Builder $query) use ($value) {
$query->where('type', $value);
});
}
}
Sorting
We also refined our sorting process and used the Spatie Query Builder package, which allowed us to achieve the result in a more straightforward and practical way. Let’s take a closer look at what we did:
// GET /apartments?sort=area,-price
$apartments = QueryBuilder::for(Apartment::class)
->defaultSort('id')
->allowedSorts(['area', 'price'])
->get();
Here, we’re demonstrating how the package handled different sorting options for our model. Let’s break it down: by default, the results are sorted in descending order based on ‘id’, and users can choose to further sort by both the ‘area’ and ‘price’ columns, either together or separately. This specific example retrieves the apartments sorted ascendingly by ‘area’ with a secondary sort on price in descending order (note the ‘-‘ symbol before the ‘price’ parameter).
Just like filtering, Laravel Query Builder Spatie allowed us to craft custom sorting classes to tailor the sorting logic according to our specific needs. Here’s how we used it:
->allowedSorts([
AllowedSort::custom('price-per-square', new PricePerSquareSort(), 'price','area'),
])
Keep in mind that while using query builder, you can fully customize it to match your preferences. This includes adjusting how you send query parameters, such as using the syntax filter[my_filter]=value. To do this, you can modify the configuration file of Spatie/laravel-query-builder, which can be published as necessary. This gives you access to a wide range of configuration options, allowing you to personalize the query builder to meet your exact needs.
php artisan vendor:publish --provider="Spatie\\QueryBuilder\\QueryBuilderServiceProvider" --tag="query-builder-config"
This is the default configuration of the query builder parameters:
'parameters' => [
'include' => 'include',
'filter' => 'filter',
'sort' => 'sort',
'fields' => 'fields',
'append' => 'append',
],
Including Relationships
When working on our project, there was a case where fetching related records became a pivotal requirement. Instead of manually eager-loading relationships in Laravel, Spatie Query Builder offered a more expressive and flexible alternative. To be more specific, we needed to fetch apartments along with their rooms:
// apartments?include=rooms
$apartmentsWithRooms = QueryBuilder::for(Apartment::class)
->allowedIncludes(['rooms']) // allows including `rooms` or `roomsCount` or `roomsExists`
->get();
Using this approach, we included ‘rooms’ in the data and automatically requested the existence and count of its related model using the ‘Exists’ and ‘Count’ suffixes, respectively. This functionality was provided by the package, which uses Laravel’s ‘withExists’ and ‘withCount’ methods under the hood.
Spatie Query Builder’s flexibility also applies to including relationships, inheriting all the features available for filtering and sorting. This means that aliases, default values, and other functionalities mentioned earlier also work when including relationships.
Selecting Fields
Another essential feature of the Spatie/laravel-query-builder package is selecting fields. This functionality allowed us to tailor our queries to fetch only the necessary data.
With Spatie Laravel Query Builder, selecting specific fields was pretty simple. Take a look at the example below:
// apartments?fields[apartments]=name,price
$filteredApartments = QueryBuilder::for(Apartment::class)
->allowedFields(['id', 'name', 'price'])
->get();
It translates to the following SQL query:
// apartments?fields[apartments]=name,price
SELECT "name","price" FROM "apartments"
Similarly, we chose fields for included models when only specific columns were needed from a related relationship.
apartments?include=rooms&fields[rooms]=id,name
QueryBuilder::for(Apartment::class)
->allowedFields('rooms.id', 'rooms.name')
->allowedIncludes('rooms');
Remember that it’s important to call allowedFields before allowedIncludes. Otherwise, the query builder wouldn’t know which fields to include for the requested includes, resulting in an exception being thrown, as mentioned in the documentation.
How do I paginate the results?
Because QueryBuilder is an Eloquent builder, pagination is just Laravel and this is where a lot of tutorials stop too early:
$apartments = QueryBuilder::for(Apartment::class)
->allowedFilters([AllowedFilter::exact('city'), AllowedFilter::partial('title')])
->allowedSorts(['price', 'created_at'])
->defaultSort('-created_at')
->paginate($request->integer('per_page', 15))
->appends($request->query()); // keep filters/sorts on page links
->appends($request->query()) is easy to forget and its absence is maddening: without it, clicking to page 2 drops every filter and sort, and the frontend appears to "reset" itself. For large tables where the COUNT(*) that paginate() runs is expensive, switch to cursorPaginate() it's dramatically faster on big datasets, at the cost of numbered pages.
The security gotcha: allowed lists are your API's attack surface
Here's the point the feature-tour tutorials bury. allowedFilters(), allowedSorts() and allowedIncludes() are not convenience helpers; they are your authorization boundary. Whatever column you name there becomes queryable by anyone who can hit the endpoint.
- Add
AllowedFilter::exact('email')to a public listing and you've built an enumeration oracle; an attacker can confirm which emails exist by probingfilter[email]=.... - Add
allowedSorts(['password_reset_token'])or worse, expose a filter on a soft-deleted or internal-status column and you've handed out a side channel into data the user should never touch. allowedIncludes('user')will happily eager-load and expose an entire related model, including attributes you assumed were private, unless your Resource hides them.
The rule we hold to: treat every entry in an allowed list as a deliberate, reviewed decision to make that column publicly queryable, never a wildcard, never "allow everything for convenience." Pair the whitelist with an API Resource that controls the output shape, and scope the base query (QueryBuilder::for(Apartment::for($user))) so filters can only ever operate over rows the caller is authorized to see. The package protects you from unlisted parameters; it does nothing about a badly chosen one.
Putting it together: a production-ready endpoint
use Spatie\QueryBuilder\QueryBuilder;
use Spatie\QueryBuilder\AllowedFilter;
use Spatie\QueryBuilder\AllowedInclude;
use App\Filters\{PriceFromFilter, PriceToFilter};
use App\Http\Resources\ApartmentResource;
public function index(Request $request)
{
$apartments = QueryBuilder::for(Apartment::query())
->allowedFilters([
AllowedFilter::exact('city'),
AllowedFilter::partial('title'),
AllowedFilter::custom('price_from', new PriceFromFilter),
AllowedFilter::custom('price_to', new PriceToFilter),
AllowedFilter::exact('is_active')->default(true),
])
->allowedSorts(['price', 'created_at'])
->defaultSort('-created_at')
->allowedIncludes([
'owner',
AllowedInclude::count('reviewsCount'),
])
->paginate($request->integer('per_page', 15))
->appends($request->query());
return ApartmentResource::collection($apartments);
}
That's a filterable, sortable, range-queryable, include-aware, paginated endpoint in one readable method, with the filter surface locked to an explicit, reviewable list.
Key takeaways
- Spatie Query Builder replaces hand-rolled
if ($request->has())filtering with whitelisted query-string filters, sorts, includes and field selection. - Use
AllowedFilter::exact/partialfor the common cases; drop toAllowedFilter::custom(aFilterclass) for range and date filters, the pattern we use across our production APIs. - Always set
defaultSort(), always->appends()your query on paginated links, and usecursorPaginate()on large tables. - Guard against the N+1 trap with
preventLazyLoading()andwhenLoaded()in your Resources. - Treat every allowed list as an authorization decision; each entry makes a column publicly queryable.
- Target the current v7 API; don't copy v5-era snippets.







.webp&w=1920&q=75&dpl=dpl_BRe7iXcVH4XjhJwZSen2jWtGWenx)