Filter Eloquent models with multiple optional filters
Tags: laravel, php, tutorial
Published: 2022-02-15 04:33:51

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
<?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:


```php
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*:

```php
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:

```php
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:
```php
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:

```php
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:
```php
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](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:
```php

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
```php
    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:
```php
    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:
```php
    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:

```php
$filterInstance = new CategoryFilter();
$query = $filterInstance($query, 'mobile-phones');
```

Same thing for the next `PhoneFilter`:

```php
$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:

```php
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 liked this post, [**follow me on twitter**](https://twitter.com/cosmeescobedo). I post tips and other content there.