11 min read

How to Build Filterable, Sortable Laravel API Endpoints with Spatie Query Builder
Nika Jorjoliani

Head of Engineering

Spatie query builder Laravel

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

Spatie query builder Laravel

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 probing filter[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 / partial for the common cases; drop to AllowedFilter::custom (a Filter class) for range and date filters, the pattern we use across our production APIs.
  • Always set defaultSort(), always ->appends() your query on paginated links, and use cursorPaginate() on large tables.
  • Guard against the N+1 trap with preventLazyLoading() and whenLoaded() 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.

 

Updated on by

Nika Jorjoliani

First published on

Nika Jorjoliani

About the author

Nika Jorjoliani

Head of Engineering at Redberry

Worked on 50+ Laravel Projects

Author of 10+ Open Source Projects

Nika is Engineering Director at Redberry, where he leads delivery across complex Laravel, Vue.js and React products for clients in fintech, wealth management, e-commerce, and SaaS. Over a decade of building large-scale Laravel applications, he has worked across multi-tenant architectures, real-time systems, third-party integrations, DevOps automation, and high-availability infrastructure.

He is the creator and maintainer of several open-source Laravel packages: Mailbox for Laravel, MCP Client for Laravel, and Laravel Packager - and co-organized Georgia's first official Laravel Meetup.

Latest News

Aug
13

Number One Again: Redberry Tops Clutch's Spring 2026 Top Laravel Developers

Redberry tops Clutch's Top 15 Laravel Developers list in the 2026 Clutch Global Awards - several years running.

Aug
11

Sponsoring Laravel Live Denmark 2026 in Copenhagen

Redberry is a sponsor of Laravel Live Denmark 2026, held August 20-21 at Werkstatt on Reffen in Copenhagen - the first year we have sponsored this event. Two of us will be there for it: Gaga Darsalia, our CEO, and Dati Chkhikvishvili, our Chief of Business.

Aug
5

Redberry on Laravel News at Laracon US 2026

At Laracon US 2026 our CEO Gaga Darsalia sat down with Eric Barnes of Laravel News to talk regulated fintech, AI-led legacy modernization, and why Redberry keeps coming back to Laracon.

img

Meet the authors

We are a 200+ people agency and provide product design, software development, and creative growth marketing services to companies ranging from fresh startups to established enterprises. Our work has earned us 100+ international awards, partnerships with Laravel, Vue, Meta, and Google, and the title of Georgia’s agency of the year in 2019 and 2021.

CONTACT US
img

Get in touch

Dati Chkhikvishvili

Chief Business Officer