Searching multiple columns with one URL parameter in laravel-query-builder original
We just released v7.3.0 of laravel-query-builder, which adds a new way to group multiple filters under a single URL parameter. Before getting into the new feature, let me show you how the basics work, so the new bit makes sense in context.
The basics
Here's a typical setup in a controller:
use Spatie\QueryBuilder\AllowedFilter; use Spatie\QueryBuilder\QueryBuilder; $users = QueryBuilder::for(User::class) ->allowedFilters( AllowedFilter::partial('name'), AllowedFilter::exact('status'), ) ->get();
With that in place, the package wires up two filters that clients can use through the query string.
A request to /users?filter[name]=john runs:
SELECT * FROM users WHERE LOWER(name) LIKE '%john%'
A request to /users?filter[status]=active runs:
SELECT * FROM users WHERE status = 'active'
And a request to /users?filter[name]=john&filter[status]=active runs:
SELECT * FROM users WHERE LOWER(name) LIKE '%john%' AND status = 'active'
Multiple filters in the URL are joined with AND. That's the default and it's what you want most of the time.
The word "allowed" in allowedFilters is doing real work. If a client tries to filter by something you haven't whitelisted, the package throws an InvalidFilterQuery exception instead of silently ignoring it or, worse, letting it through. So a request to /users?filter[email]=acme.com against the controller above results in:
Spatie\QueryBuilder\Exceptions\InvalidFilterQuery Requested filter(s) `email` are not allowed. Allowed filter(s) are `name, status`.
The exception has a 400 Bad Request status code, so by default Laravel renders it as a clean error response to the client. You don't have to do anything special to get this behavior.
The new bit: grouping filters
Now imagine you want a single search box that matches across multiple columns. The user types "john" and you want to find rows where either the name or the email contains that value.
Before v7.3.0, you'd reach for a custom callback filter:
$users = QueryBuilder::for(User::class) ->allowedFilters( AllowedFilter::exact('status'), AllowedFilter::callback('q', function ($query, $value) { $query->where('name', 'LIKE', "%{$value}%") ->orWhere('email', 'LIKE', "%{$value}%"); }), ) ->get();
Looks fine at a glance. But a request to /users?filter[status]=active&filter[q]=john runs:
SELECT * FROM users WHERE status = 'active' AND name LIKE '%john%' OR email LIKE '%john%'
Spot the bug. The OR binds looser than the AND, so this matches every row whose email contains "john", including inactive users. To fix it, you have to wrap the callback's wheres in their own nested closure. It's an easy thing to forget, and it silently returns wrong data when you do.
In v7.3.0, this becomes a one-liner. Here's how you register a group filter:
$users = QueryBuilder::for(User::class) ->allowedFilters( AllowedFilter::groupOr('q', [ AllowedFilter::partial('name'), AllowedFilter::partial('email'), ]), ) ->get();
A request to /users?filter[q]=john now runs:
SELECT * FROM users WHERE (LOWER(name) LIKE '%john%' OR LOWER(email) LIKE '%john%')
The value john is passed to every member of the group, and the conjunction joins them. There's also AllowedFilter::groupAnd() if you want all members to match instead.
Combining group filters with regular filters
Groups compose cleanly with the rest of your filters. Here's a setup that has both a top-level status filter and a group:
$users = QueryBuilder::for(User::class) ->allowedFilters( AllowedFilter::exact('status'), AllowedFilter::groupOr('q', [ AllowedFilter::partial('name'), AllowedFilter::partial('email'), ]), ) ->get();
A request to /users?filter[status]=active&filter[q]=john runs:
SELECT * FROM users WHERE status = 'active' AND (LOWER(name) LIKE '%john%' OR LOWER(email) LIKE '%john%')
The whole group is wrapped in its own parentheses, so the OR can never leak into the status clause. That's the bug pattern that used to bite people who rolled their own callback filter for cross-column search.
You can also register multiple independent groups. Each one becomes its own wrapped scope, and they're joined with AND between them. The members can be any AllowedFilter type, so you can mix partial, exact, scope, and other filters in the same group.
In closing
The upgrade is fully non-breaking. All existing filter behavior is unchanged. The new feature is documented in the filtering docs, and the pull request by Birtan Taşkın has the full reasoning behind the design.
This is one of the many packages we've created at Spatie. If you want to support our open source work, consider picking up one of our paid products.