Conditional Query Building with when() and unless()
Filtered index pages are where query building gets ugly. You have a handful of optional filters from the request, and the obvious code breaks the fluent chain with an if for each one. Laravel's when() and unless() methods let you apply a constraint only when a value is truthy, keeping the whole query in a single readable expression.
The Verbose Version
Here is the pattern most of us write first, filtering products by an optional search term and status:
$query = Product::query();
if ($request->filled('search')) {
$query->where('name', 'like', "%{$request->search}%");
}
if ($request->filled('status')) {
$query->where('status', $request->status);
}
$products = $query->latest()->paginate();
It works, but the chain is broken into pieces and the intent is buried in control flow.
The when() Version
when() takes a value and a closure. If the value is truthy, it runs the closure, passing the query and the value itself. If the value is falsy, it does nothing and returns the query untouched:
$products = Product::query()
->when($request->search, function ($query, $search) {
$query->where('name', 'like', "%{$search}%");
})
->when($request->status, function ($query, $status) {
$query->where('status', $status);
})
->latest()
->paginate();
The value is passed into the closure as the second argument, so you do not have to reach back to the request inside the closure. That second argument is easy to miss and cleans things up nicely.
The Default Callback
when() accepts an optional third argument: a closure that runs when the value is falsy. This is the equivalent of an else branch:
$products = Product::query()
->when($request->sort, function ($query, $sort) {
$query->orderBy($sort);
}, function ($query) {
$query->latest();
})
->paginate();
unless() Is the Inverse
unless() is exactly when() with the condition flipped. It runs the closure when the value is falsy. Reach for it when the natural reading of your condition is negative:
// Only show unpublished drafts to guests when they explicitly ask
$posts = Post::query()
->unless($request->user(), function ($query) {
$query->where('published', true);
})
->get();
A Word of Caution
The value you pass is evaluated for truthiness, so watch out for the integer 0, the string '0', and empty strings, which are all falsy. If a legitimate filter value could be zero, guard with something explicit like $request->filled('key') and pass a boolean instead. Used with that caveat in mind, when() and unless() turn a wall of conditionals into a chain that reads like the query it builds.
Further Reading
- Laravel documentation, "Database: Query Builder", Conditional Clauses (laravel.com/docs/13.x/queries#conditional-clauses)
- Laravel documentation, "Helpers", The Conditionable trait (laravel.com/docs/13.x/helpers)