Skip to main content

Command Palette

Search for a command to run...

When to use Laravel global scopes

Updated
4 min read
When to use Laravel global scopes
S

Lead developer at 3rdRisk.com. Proud father of two kids. Tech optimist. Apple and Laravel enthusiast.

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()));
});
4.3K views
S

Thank you for your contribution Maarten Troonbeeckx!

I agree that you need to figure out if a global scope is a smart approach in every specific use case. That is what this article is all about, actually.

For security, I really understand why global scopes are used. In larger teams, the authentication cannot be overseen in a PR now, so we're secure by default. In a healthy development process (write tests, etc.) the developer sees that he needs to use the "withoutGlobalScopes" during development, so this should not be a problem at all. But this triggers the developer to think about the security, and that is a good thing. In a PR, the reviewer will also be triggered when he sees the "withoutGlobalScopes" and makes this an area of interest to focus on in the review.

But of course, every specific use case has different needs and every team has its own preferences.

M

I have seen global scopes being applied by default in a lot of projects at my previous company and I always wondered how it makes your code base cleaner, especially when the currently logged-in user is used in the scope.

But what if you want to use Transaction in a different context, such as a command or a job, where, for example, you want to send a user his/her last 10 transactions in an email. At that point there's no authentication available and your query will return an empty result. This means that as a developer I need to be aware of this global scope being applied. Also, in every context where auth is not available or where I just want to query Transactions for something completely different, I have to opt-out using 'withoutGlobalScope()', which feels so counter intuitive :/

I feel that global scopes (applied by default) very often are being used as some kind of "security mechanism" to not accidentally display data not intended for the logged-in user. But why not define a local scope taking a User as an argument, so it can be reused in every context and then thoroughly test, for the same reason you mention in the last section?

I have never felt the need to use global scopes applied by default, which is obviously just my humble opinion :) A scenario in which global scopes are the way to go, I feel, is a tenant based application, where you could have a global scope for different tenants. But even then, if you take a look at some of the available Laravel tenancy packages, you'll see that the scope is not applied by default, but only when a tenant route is hit using middleware to apply it.

Just my respectful two cents :)

More from this blog