I've been working on a large client project for the last few months. It has a custom admin area where users can manage their members, website content and more. There are a lot of different pages for managing the different types of content, with each needing a set of filters to provide users an easy and convenient way to narrow down the results. This project is also expected to have new features post-launch, and so at an early stage I wanted to build a robust and scalable filtering system to improve iteration times and keep maintenance low.

To start, let's look at an example of how I've filtered models in the past.

How I Used To Do It

In the past I was fairly naive with filtering of records, to be honest I was naive with a lot of different core systems with applications. I'm sure everyone has used, or currently uses, this method. Effectively, just chaining on conditions when retrieving the model. Let’s set up an environment that we can use throughout this article.

Our simple application has three models. A User, Article and Tag. These are fairly simple and have the following columns:

  • User

    • id

    • username

    • is_active

    • created_at

    • updated_at

  • Article

    • id

    • title

    • author_id

    • is_published

    • created_at

    • updated_at

  • Tag

    • id

    • title

    • author_id

    • is_active

    • created_at

    • updated_at

These will have the following relations defined, for the purposes of this article I'll leave out the structure of any pivot tables.

  • User can have many Article

  • Article can have many Tag

  • User can have many Tag

This is a fairly simple setup with some basic, common, relations between our models. Our, hypothetical, customer has asked for a way to search articles as they're finding it difficult to find ones they're looking for. To take the naive approach, we might adjust our query as follows:

1 Article::where('title', 'LIKE', '%{$request->get('search')}%')
2 	->get()
3 ;
php

Perfect, we would then set up our frontend to show this filter, for our purposes this is a minimal application, so a simple input field will be suitable for us:

1 <div>
2   <form action="filter">
3     <input type="text" name="search" />
4   	<button>Filter</button>
5   </form>
6 </div>
html

Our customer is now happy that they're able to easily filter their articles. Now, they're asking if they can have a way to filter the published status of the article. No problem we say, add the extra query, and HTML:

1 Article::where('title', 'LIKE', '%{$request->get('search')}%')
2     ->where('is_published', $request->boolean('is_published'));		
3     ->get()
4 ;
php
1 <div>
2   <form action="filter">
3     <input type="text" name="search" />
4     <input type="checkbox" name="is_published" />
5     <button>Filter</button>
6   </form>
7 </div>
html

Perfect. They're now happy with the articles for now, but would be great if we could search tags and filter by the active status. I'm sure you can see where we're going here...

1 Tag::where('title', 'LIKE', '%{$request->get('search')}%')
2 	->where('is_active', $request->boolean('is_active'));		
3 	->get()
4 ;
php
1 <div>
2   <form action="filter">
3     <input type="text" name="search" />
4     
5     <input type="checkbox" name="is_active" />
6     
7   	<button>Filter</button>
8   </form>
9 </div>
html

We now have four very similar filters that could be reduced down into just two. And this would infinitely scale with other models or filter types. We could also use this opportunity as a way to make our front end reusable too.

Now, let's look at the approach I've taken with my client project.

Reusing Filter Classes

I would like to start with this by saying that is by far not a perfect solution, and is something I'm wanting to refine over time for future projects. For example, you could make use of invokable classes rather than the array structure I used here. There are lots of ways this could be tweaked and improved, I would love to hear yours! Tweet me!

Per-column classes

We're going to start by extracting our filter code into reusable classes for each column. This provides us with a reusable filter that we can reuse on different pages. For example, we might want the same filter to show on our reporting and listing pages.

To start, let's scaffold a base Filter class that will contain the relevant logic we need.

 1 abstract class Filter {
 2 	public string $view;
 3 	public string $label;
 4 
 5 	abstract function apply($request, $query);
 6 
 7 	public function render()
 8     {
 9 		return view($this->view, [
10 			'label' => $this->label,
11 			'name' => $this->name,
12 			'id' => $this->name,
13 		]);
14     }
15 }
php

This will be our starting class, let's break this down:

  • We define two variables that every filter should have:

    • view which will be the path to a blade template that will render our filter's UI

    • label which will be the readable version of name for the input's label

  • We also have two methods:

    • apply will be implemented by each of our child classes and is responsible for taking a query builder, applying conditions to it, and then returning the query builder

    • render is responsible for building our blade view

For the purposes of this article I'm only going to use Article for this section, but the concepts here will also be usable for other models too.

Now let's create two filter classes for our Article, TitleFilter and IsPublishedFilter

 1 class TitleFilter extends Filter
 2 {
 3 	public string $view = 'filters.title';
 4 	public string $label = 'Title';
 5 
 6 	public function apply($request, $query)
 7 	{
 8 		return $query->where('title', $request->get($this->name));
 9 	}
10 }
11 
12 class IsPublishedFilter extends Filter
13 {
14 	public string $view = 'filters.published';
15 	public string $label = 'published';
16 
17 	public function apply($request, $query)
18     {
19 		return $query->where('is_published', $request->boolean($this->name));
20     }
21 }
php

I'm not going to include the blade views here, but we might assume TitleFilter would be a text input and then IsPublishedFilter might be a switch or a checkbox.

Now we need to "register" these with a model somehow. The way I did this in the project was by creating a Filterable trait that could then be used on any model I needed to add filters to, this trait looked something like:

 1 trait Filterable
 2 {
 3 	public static function getFilters()
 4 	{
 5 		return static::$filters ?? [];
 6 	}
 7 
 8 	public function scopeFiltered($query, $request)
 9 	{
10 		foreach(self::getFilters() as $filter) {
11 			$query = resolve($filter)->apply($request, $query);
12 		}
13 
14 		return $query;
15 	}
16 }
php

This trait defines two methods, getFilters which returns the filters defined against the model or an empty array, and scopeFiltered which uses Eloquent Scopes to provide a chainable method on the Eloquent builder for this method. scopeFiltered simply accepts the current query builder and request and then instances our defined filters and applies them to the builder, passing in the request.

Our model might look something like:

1 class Article {
2 	use Filterable;
3 
4 	public static $filters = [
5 		TitleFilter::class,
6 		IsPublishedFilter::class,
7 	];
8 }
php

We could then apply the filters that are in the request by calling it before any other methods we like:

1 Article::filtered($request)->limit(20)->get();
php

If title or published is defined inside our request, then it'll be applied as conditions within the query builder.

You would then be able to look over the model's static $filters array and call render on each filter within the blade template for your page. I'm not going to show that here, but it would be very similar to the scopeFiltered function but by calling render.

This is great, but we've not really reduced the amount of code we have, but have extracted our filters for easier reusability. Let's look next at extracting these to more generic filter classes where we can pass options through.

Building generic filter classes

The next step is to start with as generic filters as possible. You'll be able to easily expand on them, creating more custom filters as needed, but that should be the exception. Now, let's take our two filters and extract them down into something that's more reusable. Let's create a TextFilter and a BooleanFilter.

First, we'll need to update our Filter abstract class to allow us to define configurable columns, let's do that first.

 1 abstract class Filter {
 2 	public string $view;
 3 	public string $label;
 4 	public string $column;
 5 	public string $name;
 6 
 7 	abstract function apply($request, $query);
 8 
 9 	public function setOptions($options)
10 	{
11 		$this->label = $options['label'];
12 		$this->column = $options['column'];
13 		$this->name = $options['name'];
14 
15 		return $this;
16 	}
17 
18 	public function render()
19     {
20 		return view($this->view, [
21 			'label' => $this->label,
22 			'name' => $this->name,
23 			'id' => $this->name,
24 		]);
25     }
26 }
php

What we've done here is added two new variables name and column each to allow us to extract the request name for our filter and the column name we're applying it to. We've then added a new setOptions method which will be used to define these settings when we're configuring them and filtering.

Now let's amend our TitleFilter and IsPublishedFilter to our new TextFilter and BooleanFilter.

1 class TitleFilter extends Filter {
2 	public string $view = 'filters.text';
3 	
4 	public function apply($request, $query)
5 	{
6 		return $query->where($this->column, $request->get($this->name));
7 	}
8 }
php
1 class BooleanFilter extends Filter {
2 	public string $view = 'filters.switch';
3 
4 	public function apply($request, $query)
5 	{
6 		return $query->where($this->column, $request->boolean($this->name));
7 	}
8 }
php

What we've done is refactored our two column based filters into two generic TextFilter and BooleanFilter classes. They're doing the same thing, but are using much more generic structures, allowing us to easily reuse these.

You might be wondering, how are we going to use this within our Model? Well, let's start by showing the new $filters array configuration:

 1 // model
 2 public static $filters = [
 3 	TextFilter::class => [
 4 		'column' => 'title',
 5 		'name' => 'title',
 6 		'label' => 'Search Title',
 7 	],
 8 	BooleanFilter::class => [
 9 		'column' => 'is_published',
10 		'name' => 'published',
11 		'label' => 'Published?',
12 	],
13 ];
php

With this setup our key is the class that is our filter, we then have an array of options for each entry. You can also change this to instead instantiate a class, passing the config into the constructor.

Now, we'll want to change our Filterable trait to use this new structure:

 1 trait Filterable
 2 {
 3 	public static function getFilters()
 4 	{
 5 		return static::$filters ?? [];
 6 	}
 7 
 8 	public function scopeFiltered($query, $request)
 9 	{
10 		foreach(self::getFilters() as $filter => $options) {
11 			$query = resolve($filter)->setOptions($options)->apply($request, $query);
12 		}
13 
14 		return $query;
15 	}
16 }
php

That's it! We can now reuse these two filters in any of our models. For example, let's take this approach and apply some filters to our Tag model:

 1 // Tag model
 2 public static $filters = [
 3 	BooleanFilter::class => [
 4 		'name' => 'active',
 5 		'label' => 'Active',
 6 		'column' => 'is_active',
 7 	],
 8 	TextFilter::class => [
 9 		'name' => 'title',
10 		'label' => 'Title',
11 		'column' => 'title',
12 	],
13 ];
php

Simple! If we needed a custom filter for a Tag which has some odd logic or structure we could then extend one of these base filters and then implement our custom logic inside the apply method.

This can also be expanded to also support relationships via dot notation, however, this article has already got quite long, so I might cover that in a follow-up.

As I mentioned earlier though, this is likely not the best solution and there are packages out there that handle Eloquent based filtering, however, I wanted to try something like this and I think it worked out for the project. Let me know on Twitter how you filter Eloquent models!