A nicer way of overriding Eloquent global scopes

The standard method for removing a global scope from an Eloquent model is a little clunky. We can do better.

A very brief introduction to global scopes

A global scope allows you to apply the same constraints to every query for a given model. Here’s a simple example, which builds on the official docs, and ensures that individuals under the age of 18 are excluded from all User model queries.

// app/User.php

<?php

namespace App;

use App\Scopes\AgeScope;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Builder;

class User extends Model
{
    /**
     * The "booting" method of the model.
     *
     * @return void
     */
    protected static function boot()
    {
        parent::boot();

        static::addGlobalScope('age', new AgeScope);
    }
}

// app/Scopes/AgeScope.php
<?php

namespace App\Scopes;

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

class AgeScope implements Scope
{
    /**
     * Restrict results to users aged 18 or over.
     *
     * @param Builder $builder
     * @param Model  $model
     */
    public function apply(Builder $builder, Model $model)
    {
        $builder->where('age', '>=', 18);
    }
}

All well and good, but what if we occasionally want to include these wayward youths in our query results?

Disabling a global scope, the Laravel way

Laravel lets you disable a global scope using the withoutGlobalScope method:

// Retrieves all users, regardless of age.
$user->withoutGlobalScope(AgeScope::class)->get();

This works, but it’s not exactly intuitive. You need to know whether the global scope is defined in a separate class, or a closure, and then you need to know the name of the class or closure.

In short, it’s all a little nuts-and-bolts, and very un-Laravel. We can do better.

Disabling a global scope, a better way

It would be much nicer if we could pollute our query results with a misery of slouching teens simply by calling a withYouths method on the User object:

User::withYouths()->get();

No nuts, no bolts, just an intuitively-named method, which describes exactly what it does.

As it turns out, Laravel allows us to do exactly this. It’s just a little hard to find (and entirely undocumented).

Let’s start by taking a look at the final implementation, and then figure out how it all works.

<?php

namespace App\Scopes;

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

class AgeScope implements Scope
{
    /**
     * Restrict results to users aged 18 or over.
     *
     * @param Builder $builder
     * @param Model  $model
     */
    public function apply(Builder $builder, Model $model)
    {
        $builder->where('age', '>=', 18);
    }

    /**
     * Extend the query builder with the needed functions.
     *
     * @param Builder $builder
     */
    public function extend(Builder $builder)
    {
        $builder->macro('withYouths', function (Builder $builder) {
            return $builder->withoutGlobalScope($this);
        });
    }
}

As you can see, we’ve added a new extend method to our global scope class. This method registers a new macro with the Eloquent Builder class, which simply removes the AgeScope global scope (using the now-familiar withoutGlobalScope method).

All pretty simple stuff, but how does the extend method get called? That’s where things get a little more complicated.

A peek behind the curtain

The key lies in the Illuminate\Eloquent\Builder::withGlobalScope method. If you dig through the code, you’ll see that Laravel explicitly checks whether the scope model has an extend method, and calls it:

public function withGlobalScope($identifier, $scope)
{
    $this->scopes[$identifier] = $scope;

    if (method_exists($scope, 'extend')) {
        $scope->extend($this);
    }

    return $this;
}

Let’s step through this, line-by-line:

  1. Laravel adds the AgeScope to the builder’s scopes array, using AgeScope::class as the identifier.
  2. Laravel checks whether the AgeScope class has a method named extend, and then calls it.
  3. AgeScope::extend registers the withYouths method as a builder macro.

A little convoluted, but it works smoothly, and has no impact on performance or query count.

Sign up for my newsletter

A monthly round-up of blog posts, projects, and internet oddments.