I recently tackled this very same issue when working on a client's project. I had to display a list of leads, and allow the user to conditionally apply one or more of the available filters, like client name, lead status etc.
Of course, this isn't a complicated nor a rare issue. In fact, I had to implement it one way or another in almost every project that I've worked on in my career as a developer. The only problem here is that more often than not, you find yourself writing if statement for each one of the filters in order to check if it should be applied on the query, which, let's be honest, makes the code very ugly.
If you want to skip the whole setup phase and get right down to business, I get you. The actual magic starts here.
Let's take the following example. We have two models: Post
and Comment
, linked by a one-to-many relationship (a post has many comments, a comment belongs to a post). I tried to keep things as simple as possible, for example - instead of creating another Topic
model for our post topics, I used strings. Anyways, here are our models and migrations:
// App/Models/Post.php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Post extends Model
{
use HasFactory;
public function comments(): HasMany
{
return $this->hasMany(Comment::class);
}
}
// App/Models/Comment.php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Comment extends Model
{
use HasFactory;
public function post(): BelongsTo
{
return $this->belongsTo(Post::class);
}
}
// database/migrations/2020_10_22_180415_create_posts_table.php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreatePostsTable extends Migration
{
public function up()
{
Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->string('topic');
$table->string('username');
$table->string('title');
$table->text('content');
$table->timestamps();
});
}
public function down()
{
Schema::dropIfExists('posts');
}
}
// database/migrations/2020_10_22_180506_create_comments_table.php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateCommentsTable extends Migration
{
public function up()
{
Schema::create('comments', function (Blueprint $table) {
$table->id();
$table->foreignId('post_id')->constrained()->cascadeOnDelete();
$table->string('username');
$table->text('content');
$table->timestamps();
});
}
public function down()
{
Schema::dropIfExists('comments');
}
}
Now, for the sake of our example, I created a page that only displays a list of posts, each post block contains its topic, username and comments count. Here's how it looks:
To display the posts, I used the following code on my PostsController
class:
// App/Http/Controllers/PostsController.php
<?php
namespace App\Http\Controllers;
use App\Models\Post;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\Request;
class PostsController extends Controller
{
public function index(Request $request)
{
$posts = Post::query()
->latest()
->withCount('comments')
->get();
return view('posts.index')->with(['posts' => $posts]);
}
}
Applying The Filters
As you can see, there are two filters about the posts list: topic and popularity. The topic filter allows the user to choose a specific topic, while the popularity filter has two options: all and popular, which are posts that have more than 10 comments.
My first step (which is totally optional) towards filtering our query was to extract the query to a separate method called applyFilters
. It just makes the code more readable, in my opinion.
The next (and final one) is pretty simple. We need to check if the query string contains a non-empty value of our filter key (topic
of popularity
), and if so - apply it on the filter. In a non-Laravel application, one may do something like this:
// App/Http/Controllers/PostsController.php
<?php
namespace App\Http\Controllers;
use App\Models\Post;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\Request;
class PostsController extends Controller
{
public function index(Request $request)
{
$posts = $this->applyFilters($request);
return view('posts.index')->with(['posts' => $posts]);
}
private function applyFilters(Request $request): Collection
{
$query = Post::query()
->withCount('comments')
->latest();
if ($topic = $request->query('topic')) {
$query->where('topic', $topic);
}
if ($request->query('popularity')) {
$query->having('comments_count', '>', 10);
}
return $query->get();
}
}
Well, that works. But what if we had 10 more filters? It kind of looks off to me right now, so I don't even want to start thinking on how it'll look with dozens more filters. The thing that bothers me is not the fact that every filter has its own reference in the code. As you can see, each filter may have a slightly different logic. The topic
filter is a simple where
, while the popularity
filter is based on a value that was queried using a sub-query (that's what withCount
does in order to help us avoid an n+1 problem), so we have to use having
instead of where
. And of course, your application may have filters that are much more complex.
Fortunately, Laravel's query builder is equipped with the when
method, that lets us conditionally apply clauses on our queries. From the official Laravel documentation:
Sometimes, you may want clauses to apply to a query only when something else is true. For instance, you may only want to apply a where statement if a given input value is present on the incoming request.
The first argument when
receives is the value, in our case, of the filter. If it isn't false/null/empty (basically - if it's evaluated as true
) - the second argument, which is a closure, will be executed. The closure will accept an Illuminate\Database\Eloquent\Builder
instance (or Illuminate\Database\Query\Builder
if not in an Eloquent context) as its first argument, and the conditional argument you used in the when
method as the second (which you don't have to use, as you'll see in the following example). The closure should return a scoped query builder.
So after a short refactor, this is how our PostsController
looks like:
// App/Http/Controllers/PostsController.php
<?php
namespace App\Http\Controllers;
use App\Models\Post;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\Request;
class PostsController extends Controller
{
public function index(Request $request)
{
$posts = $this->applyFilters($request);
return view('posts.index')->with(['posts' => $posts]);
}
private function applyFilters(Request $request): Collection
{
return Post::query()
->withCount('comments')
->latest()
->when($request->query('topic'), fn(Builder $query, $topic) => $query->where('topic', $topic))
->when($request->query('popularity'), fn(Builder $query) => $query->having('comments_count', '>', 10))
->get();
}
}
As you can see, I used short closures (arrow functions) in order to make it even more beautiful. If you're not familiar with short closures that were introduced in PHP 7.4, Brent Roose from Spatie wrote an excellent Blog Post about them.
That's it, now we can filter our results, and we even made it look nice! Additionally, by using the query string to apply our filters, each state of our data can have its own unique link!