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.

Bend Craft to Your Will

Our newsletter helps you make the most of Craft. Join for free, leave any time.