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:
- Laravel adds the
AgeScope
to the builder’sscopes
array, usingAgeScope::class
as the identifier. - Laravel checks whether the
AgeScope
class has a method namedextend
, and then calls it. AgeScope::extend
registers thewithYouths
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.