February, 08 2022

Streamlining Your Controllers With Eloquent Scopes

Guess what? This article is a completion of one of the (many) drafts I put down last year but couldn't publish. So here we go!

In this article, I am going to be showing you how you can use Laravel Eloquent Scopes to clean up your controller methods. Besides, if you are new to Laravel, then you definitely need to understand what Eloquent Scope is all about.

Straight to business..

Eloquent Scopes

Scopes are database query constraints that can be applied automatically or manually whenever a database call is made from your Eloquent Model.

A scope can either be global or local. It also worthy to note that a global scope, when defined, is automatically applied on every model query, while you have to manually apply a local scope.

Personally, I seldom make use of global scopes, but prefer using local scopes.

Next, I am going to be explaining how you can use it (local scope), then how it can help clean up a controller and write clean code.

Using Local Scopes

In Laravel, a local scope is just like any other PHP method that accepts a parameter an instance of Eloquent Builder (Laravel takes care of that) then returns the same builder instance. When doing this, the method name should be prefixed with "scope" and it is usually defined inside an Eloquent Model. I will show you an example below.

In a recent codebase I have been working on, I had a resource api that returns a list of all the admins on the system, but I don't want to return the super admin and also the current admin making that request.

Therefore, without local scopes, here is what my controller method looks like:

public function index()
{
    $admins = User::where('email', '!=', User::SUPER_ADMIN_USER_EMAIL)
        ->where('id', '!=', Auth::id())
        ->paginate(self::DEFAULT_PAGINATION);

    return responder()->success($admins, AdminTransformer::class);
}

Now let's create some local scopes for these query constraints. Inside our User model, we will add the following scopes: exceptCurrentAdmin(), exceptSuperAdmin(), exceptImportantAdmins().

public function scopeExceptSuperAdmin($query)
{
    return $query->where('email', '!=', self::SUPER_ADMIN_USER_EMAIL);
}

public function scopeExceptCurrentAdmin($query)
{
    return $query->where('id', '!=', Auth::id());
}

public function scopeExceptImportantAdmins($query)
{
    return $query->exceptCurrentAdmin()->exceptSuperAdmin();
}

I hope this code explains itself, so let's head over to our controller and clean up the index method.

public function index()
{
    $admins = User::exceptImportantAdmins()->paginate(self::DEFAULT_PAGINATION);

    return responder()->success($admins, AdminTransformer::class);
}

You can even take this further by grouping these local scopes. We can do this by creating a PHP trait that takes a group of related scopes which can be applied to any Eloquent Model that needs it.

Grouping Local Scopes

I usually do this whenever I observe I have repeated a few query constraints across different models. I just move the scoped query to a trait and use that trait on any Eloquent Model that needs it. Here is a simple example from one of the projects I have worked on:

... 
trait withMonthBuilder
{
    public function scopePreviousMonth($query)
    {
        $lastMonthFromDate = Carbon::now()->subMonth()->startOfMonth()->toDateString();
        $lastMonthToDate = Carbon::now()->subMonth()->endOfMonth()->toDateString();

        return $query->whereBetween('created_at', [$lastMonthFromDate, $lastMonthToDate]);
    }

    public function scopeCurrentMonth($query)
    {
        $lastMonthFromDate = Carbon::now()->startOfMonth()->toDateString();
        $lastMonthToDate = Carbon::now()->toDateString();

        return $query->whereBetween('created_at', [$lastMonthFromDate, $lastMonthToDate]);
    }
}

Looking at this example, you will notice I needed to compare the models created in the current month and the previous month, so I created a currentMonth() and a previousMonth() scope. And since I needed to do this same query constraints on other models, I moved them into a trait file and use it across my Eloquent models.

Which enabled me to do this:

User::currentMonth()->count(); // Total users created this month.

User::previousMonth()->count(); // Total users created the previous month.

Cool right?

Fat Models, Skinny Controller

I certainly hope by now you understand how to use eloquent scopes to streamline your controllers and keep it clean. This approach is called "Fat Models, Skinny Controller" and the likes of DHH, Taylor Otwell, and Adam Wathan have recommended it.

Join my inner circle newsletter

Be the first to hear about anything I publish, launch, or think is helpful for you. Subscribe here

Hey, have you tried Litehost lately ?

Litehost is a web hosting platform for PHP & Laravel developers with Composer, Git, PHP & CLI pre-installed. Try it now