When to use Laravel global scopes

When to use Laravel global scopes

Laravel global scopes are great, but I don't see them used a lot. Instead, I see a lot of local scopes being used to achieve the same thing. With proper implementation of global scopes, the code and security would be greatly improved. Let me illustrate this with a simple example.

The local scope way

In our codebase, we have a model Transaction that stores transactions for our users. If we want to get the transaction for a logged-in user from the database, we could do it like this:

$transactions = Transaction::where('user_id', auth()->id())->get();

Since we would use this a lot throughout the codebase, it would make sense to create a local scope in the Transaction model like this:


namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class Transaction extends Model
{
    // ...

    public function scopeForLoggedInUser($query): void
    {
        $query->where('user_id', auth()->id());
    }
}

With this local scope, we can make the query like this:

$transactions = Transaction::forLoggedInUser()->get();

This is a nice DRY (Don't Repeat Yourself) refactor that cleans it up a little.

A question you should ask yourself

Local scopes are awesome, I use them where I can to keep the code DRY. But I learned to ask myself this question when I create a local scope: “Will the majority of the queries for this model use this local scope”.

If the answer is no, keep the local scope. It makes a lot of sense to use it.

When the answer is yes

This would be the point when considering a global scope. A global scope is always applied to all queries of a given model. You can create a global scope simply by creating a class that implements Illuminate\Database\Eloquent\Scope.

<?php

namespace App\Models\Scopes;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;

class TransactionsForLoggedInUserScope implements Scope
{

    public function apply(Builder $builder, Model $model)
    {
        $builder->where('user_id', auth()->id());
    }
}

After that, you should let your model be aware of the global scope:

<?php

namespace App\Models;

use App\Models\Scopes\TransactionsForLoggedInUserScope;
use Illuminate\Database\Eloquent\Model;

class Transaction extends Model
{
    // ...

    protected static function booted(): void
    {
        static::addGlobalScope(new TransactionsForLoggedInUserScope());
    }
}

If you would now get all the transactions, it will only return the transactions of the logged-in user.

Transaction::all();

Removing a global scope

There are some scenarios thinkable where you want to create a Transaction query without the global scope applied. For example, in an admin overview or for some global statistic calculations. This can be done by using the withoutGlobalScope method:

Transaction::withoutGlobalScope(TransactionsForLoggedInUserScope::class)->get();

Anonymous Global Scopes

There is also a way to create a global scope without the use of an extra file. Instead of pointing to the TransactionsForLoggedInUserScope class, you can include the query like this:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Transaction extends Model
{
    // ...

    protected static function booted(): void
    {
        static::addGlobalScope('for_logged_in_users', function (Builder $builder) {
            $builder->where('user_id', auth()->id());
        });
    }
}

Personally, I don't like anonymous global scopes since it can bloat your model if the query is a bit complex. To be consistent throughout the entire codebase, I always tend to use the external file global scopes, even if the query is as simple as in our example.

The downside of global scopes

I won't lie to you, if you're working on a codebase with global scopes and are not aware of them, you might fall into situations where nothing makes sense anymore. It made me question my career choices one (very bad) day 😂. You will get totally unexpected results when tinkering. If this has happened once, you've learned that it is good to have the withoutGlobalScopes method in your tool belt while working in a codebase you're not quite familiar with.

Security

If the security of your app relies on a global scope, it's dangerous when you or a fellow developer will make changes to it in the future. Imagine in our example that someone would remove or change the global scope. Then all results for a user's transactions would be completely faulty! This is why it's really important to implement tests for global scopes, so this can't happen. A typical (PEST) test for our example would look like this:

it("should only get the transactions for the logged-in user", function (){
    $user = User::factory()->create();
    $otherUser = User::factory()->create();

    $transaction_1 = Transaction::factory()->create(['user_id' => $user->id]);
    $transaction_2 = Transaction::factory()->create(['user_id' => $otherUser->id]);

    $this->actingAs($user);
    expect(Transaction::all())->toHaveCount(1)
        ->and($transaction_1->is(Transaction::first()));
});