Laravel
Re-useable Eloquent Model Filters
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 manyArticle
Article
can have manyTag
User
can have manyTag
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 ;
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>
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 ;
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>
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 ;
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>
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 }
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 UIlabel
which will be the readable version ofname
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 builderrender
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 }
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 }
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 }
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();
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 }
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 }
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 }
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 ];
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 }
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 ];
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!