Using a "scope" macro for better filtering

Last updated

Many applications have "index" pages or endpoints that return resources in a list or table, and often they allow these resources to be filtered and sorted. Imagine a page of your app's users: you likely want to be able to sort them by last name, or last login, not to mention being able to search for a record by various attributes.

Inevitably, as more and more flexibility is added, the logic and queries behind these pages tend to become more complex, and the controller methods or services that contain them continue to grow. If you're anything like me, you'll soon find yourself wading through a sea of ->when() methods and conditional statements, trying to keep things tidy with all the various filters... but ultimately it gets totally out of control.

I've been there many times, and recently I found myself starting down that road again. Sure, I could create a multitude of scopes to use, but it'll still result in me or another developer needing to crack open the controller just to add YAS (Yet Another Scope) to the query.

There has to be a better way. And, thankfully, I think I've come up with one.

The scope macro

Introducing the scope macro, a little gem that I hope you also find useful in your Laravel app.

The idea behind this macro is that you can pass a "filter class" that contains an organized set of logic, which can expanded and tested separately from your controller or service.

For example:

1public function index(Request $request) {
2 $posts = Post::scope(new PostFilters($request))->paginate();

Nice and tidy!

Filter Class

A filter class that's being passed into the scope method only needs to implement a single getQuery() method, which accepts an Illuminate\Database\Eloquent\Builder argument. Then within that method you can then add conditionals, scopes, etc.

Here's an example of a filter class:

3namespace App\\Scopes;
5use Illuminate\\Database\\Eloquent\\Builder;
6use Illuminate\\Http\\Request;
8class PostFilters {
9 protected Request $request;
11 public function __construct(Request $request) {
12 $this->request = $request;
13 }
15 public function getQuery(Builder $query) {
16 $this->filterByDates($query);
17 $this->filterByTitle($query);
19 return $query;
20 }
22 protected function filterByDates($query) {
23 $query->when($this->request->filled('start_date'), function ($query) {
24 $query->where('start_date', '>=', $this->request->input('start_date'));
25 });
27 $query->when($this->request->filled('end_date'), function ($query) {
28 $query->where('end_date', '<=', $this\>request->input('end_date'));
29 });
30 }
32 protected function filterByTitle($query) {
33 $query->when($this->request->filled('search'), function($query) {
34 $query->search($request->input('search'));
35 });
36 }

As for the scope macro, just open your AppServiceProvider and add the following:

3use Illuminate\\Database\\Eloquent\\Builder;
5class AppServiceProvider extends ServiceProvider {
6 public function boot() {
7 Builder::macro('scope', fn ($scope) => $scope->getQuery($this));
8 }

And that's it! A tidy, testable way to keep your controller methods small, and your list queries manageable.