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:

Posts Page

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!

Filtered Posts Page