Filter Eloquent models with multiple optional filters
Feb 7, 2022
Often we need to filter eloquent models when displaying to a view. If we have a small number of filters this can be fine, but if you need to add more than a couple the controller might get cluttered and difficult to read.
This is especially true when dealing with multiple optional filters that can be used in conjunction.
However, there are ways to create these filters, and even make them reusable. By the end of this article, you will be better equipped to deal with complex filtering options in your projects.
Defining the problem
Let's say, as an example that I have a controller method that returns every product in our store, this can be for an API, passing it to a blade template, or whatever the case might be, the controller will probably look something like this:
<?php
namespace App\Http\Controllers;
use App\Models\Product;
use Illuminate\Http\Request;
class ProductsController extends Controller
{
public function index()
{
return Product::all();
}
}
However, this is not realistic at all. Most often than not, we need to add some filtering, for example, let's say we want to get only the products in a given category, we could do that by sending the category slug in our query string and then filtering using the slug. So our new controller would look something like this:
class ProductsController extends Controller
{
public function index()
{
if ($request->filled('category')) {
$categorySlug = $request->category;
return Product::whereHas('category', function ($query) use ($categorySlug) {
$query->where('slug', $categorySlug);
});
}
return Product::all();
}
}
If we only need one filter, this is fine. But, look what happens if we introduce only two more filtering options:
class ProductsController extends Controller
{
public function index(Request $request)
{
$query = Product::query();
if ($request->filled('price')) {
list($min, $max) = explode(",", $request->price);
$query->where('price', '>=', $min)
->where('price', '<=', $max);
}
if ($request->filled('category')) {
$categorySlug = $request->category;
$query->whereHas('category', function ($query) use ($categorySlug) {
$query->where('slug', $categorySlug);
});
}
if ($request->filled('brand')) {
$brandSlug = $request->brand;
$query->whereHas('brand', function ($query) use ($brandSlug) {
$query->where('slug', $brandSlug);
});
}
return $query->get();
}
}
We've introduced two more filters, one for the brand slug and one for the price range. In my opinion, this is too busy, it's hard to keep track of what is going on here, and it can get a lot worse quickly if we need to add more filtering options.
When you search for a product on eBay, for example, you often get more than ten optional filters. We need to look for another approach to doing this.
The dream
What if, instead of having all those filters in our controller, we could do something like this:
class ProductsController extends Controller
{
public function index(ProductFilters $filters)
{
return Product::filter($filters)->get();
}
}
Here we are receiving a ProductFilters
class that presumably has all of our filters and, then we can apply them to a query scope named filter
. This makes our controller very slim, and it's easy to guess what it's happening. We are filtering the products, if we need to know more details, we can then look into the ProductFilters
class.
Implementing the new approach
First, let's add the scope to our model:
class Product extends Model
{
use HasFactory;
public function category()
{
return $this->belongsTo(Category::class);
}
public function brand()
{
return $this->belongsTo(Brand::class);
}
// This is the scope we added
public function scopeFilter($query, $filters)
{
return $filters->apply($query);
}
}
In this scope, we receive a QueryBuilder
instance and the ProductFilters
instance we passed down from the controller. Then we call the apply
method on this $filter
instance.
Up to this point, we know the ProductFilters
class will look something like this:
namespace App\Filters;
class ProductFilters
{
public function apply($query)
{
if (request()->filled('price')) {
list($min, $max) = explode(",", $request->price);
$query->where('price', '>=', $min)
->where('price', '<=', $max);
}
if (request()->filled('category')) {
$categorySlug = $request->category;
$query->whereHas('category', function ($query) use ($categorySlug) {
$query->where('slug', $categorySlug);
});
}
if (request()->filled('brand')) {
$brandSlug = $request->brand;
$query->whereHas('brand', function ($query) use ($brandSlug) {
$query->where('slug', $brandSlug);
});
}
return $query->get();
}
}
This code will work, but it isn't that much better than just having the filters in the controller. Instead of doing this, I would like to have separate filter classes that only contain their filtering logic.
Let's do that for the categories filter:
namespace App\Filters;
class CategoryFilter
{
function __invoke($query, $categorySlug)
{
return $query->whereHas('category', function ($query) use ($categorySlug) {
$query->where('slug', $categorySlug);
});
}
}
Here we have a self-contained class that we can even use in other models, let's say we have a blog attached to the store, we can use this same filter for the blog posts.
By the way, if you are not familiar with the __invoke
magic method, you can find more information here: https://www.php.net/manual/en/language.oop5.magic.php#object.invoke. In short, it just lets us call an instance of a class as if we were calling a function.
After we create the classes for our filters, we need to find a way to call them from the ProductFilters
class. There are many ways to do this, for this article, I'm going to take the following approach:
namespace App\Filters;
class ProductFilters
{
protected $filters = [
'price' => PriceFilter::class,
'category' => CategoryFilter::class,
'brand' => BrandFilter::class,
];
public function apply($query)
{
foreach ($this->receivedFilters() as $name => $value) {
$filterInstance = new $this->filters[$name];
$query = $filterInstance($query, $value);
}
return $query;
}
public function receivedFilters()
{
return request()->only(array_keys($this->filters));
}
}
This is the finished class, let me break it down and explain each part.
How does this work?
First, let's start with the $filters
property
protected $filters = [
'category' => CategoryFilter::class,
'price' => PriceFilter::class,
'brand' => BrandFilter::class,
];
Here I've defined every optional filter for the products, the key of the array is the key that will be used to check if the filter it's in the request, and the value of the array is the class that defines the filter behavior. This makes it easy for us to add more filters, when we need to add a new filter we just create the new filter class and add that class to this array.
The second part of the puzzle is this method:
public function receivedFilters()
{
return request()->only(array_keys($this->filters));
}
This method it's used to find what filters are being used in the request so we only apply the needed filters. We're also calling the only
method and passing to it the keys of the $filters
array to prevent us from attempting to call a filter class that doesn't exist. Let's say someone sends the filter "size" but we currently don't support it, this will make it so that the request key is ignored.
Now let's talk about the meat of this class, the apply
method:
public function apply($query)
{
foreach ($this->receivedFilters() as $name => $value) {
$filterInstance = new $this->filters[$name];
$query = $filterInstance($query, $value);
}
return $query;
}
This method is simply a for each that loops through the received filters creates a class of the filter, and then calls the filter with the $query
and the value received in the request.
For example, let's say we get a request that looks like this:
['category' => 'mobile-phones', 'price' => '100,150']
This class will first create an instance of CategoryFilter
and then pass the $query
and "mobile-phones" to it. Essentially, it will do this:
$filterInstance = new CategoryFilter();
$query = $filterInstance($query, 'mobile-phones');
Same thing for the next PhoneFilter
:
$filterInstance = new PhoneFilter();
$query = $filterInstance($query, '100,150');
Then after all the queries are applied, it will return the $query
object with every requested filter. And in that is what we receive when we apply the scope in the controller:
class ProductsController extends Controller
{
public function index(ProductFilters $filters)
{
return Product::filter($filters)->get();
}
}
Conclusion
We've created an extendible way to make multiple filters for our products, and there are things here that we can improve, for example adding validation for the filter value or creating a base class from ProductFilters
so we can add the same type of filtering to other models. But I'll leave that as a challenge to you.
If you have any doubts or comments about this, shoot me an email at [email protected] or tweet at me, at @cosmeescobedo. I'll be happy to answer your questions.
You might also like