I am working on a Laravel project that has moderated reviews. Most of the time we only deal with reviews that have been approved, so the Review
model has an ApprovedScope
Global Scope to only show approved review, but staff members need to see un-approved reviews in our Nova admin so that we can approve, (or reject), reviews. Removing a Global Scope from a model for all of Nova is trickier than it appears at first glance, but there’s at least one way to do it, and maybe more.
Things that do not work
Call withoutGlobalScopes()
in the Nova::serving()
callback
Googling “remove global scope nova” turns up a post on Medium called Add or remove global scopes in Nova. Sounds promising! It claims we can remove global scopes by adding this to the boot method of the `NovaServiceProvider
:
class NovaServiceProvider extends NovaApplicationServiceProvider
{
/**
* Bootstrap any application services.
*
* @return void
*/
public function boot()
{
Nova::serving(function () {
\App\Rating::withoutGlobalScopes();
});
}
}
Cool! But it doesn’t work. When adding a global scope to a model using the model’s boot method like the Laravel documentation shows, the model’s boot
method is called after the Nova::serving()
callback. So in this situation we remove nothing from the Rating
, then add a global scope later when the model is booted.
Call withoutGlobalScopes()
in the model’s booted event
If a model has a global scope added during its boot then we should be able to remove it during the ‘booted’ event – or at least that’s what I thought, so I tried this in NovaServiceProvider::boot
:
Nova::serving(function() {
Event::listen('eloquent.booted: App\Rating', function($rating) {
Rating::withoutGlobalScopes();
});
});
No dice. I’m not certain why this doesn’t work, but some XDebug spelunking shows me that:
- There’s no
withoutGlobalScopes()
method on Laravel models, but when the model gets a method call that it doesn’t recognize it passes it to the model’s Builder instance. - The
Builder
does have a withoutGlobalScopes
method, so we don’t get an InvalidMethodException
. In the Builder’s withoutGlobalScopes
call the ApprovedScope
is removed from the Builder
’s list of global scopes. - But the Global Scope is still applied to the query. My best guess is that a different query builder is used to actually generate results, or that later, when the models are about to be retrieved from the DB, the model re-passes the list of Global Scopes to the builder – and since we haven’t removed the Global Scope from the model it gets re-added to the Query Builder. If anyone knows what’s actually going on here I would love to know in the comments or on Twitter.
Something that does work
We need to remove the Global Scope after the model has booted and prevent it from being passed or re-passed to the Query Builder. How about a Model::withoutGlobalScopes
method? The model’s addGlobalScope
method comes from the Illuminate\Database\Eloquent\Concerns\HasGlobalScopes
trait, and stores the global scopes in a static::$globalScopes[static::class]
array. Creating our own HasRemovableGlobalScopes
trait, with withoutGlobalScope
and withoutGlobalScopes
methods that mirror the signature of the Illuminate\Database\Eloquent\Builder
withoutGlobalScope
and withoutGlobalScopes
methods can solve the problem, (also available as a Gist):
<?php
namespace App\Concerns;
use Closure;
use Illuminate\Database\Eloquent\Scope;
use Illuminate\Support\Arr;
trait HasRemovableGlobalScopes {
/**
* @param \Illuminate\Database\Eloquent\Scope|string $scope
*/
public static function withoutGlobalScope( $scope )
{
if (is_string($scope) && is_array(static::$globalScopes[static::class])) {
Arr::forget(static::$globalScopes[static::class], $scope);
} elseif ($scope instanceof Closure) {
Arr::forget(static::$globalScopes[static::class], spl_object_hash($scope));
} elseif ($scope instanceof Scope) {
Arr::forget(static::$globalScopes[static::class], get_class($scope));
}
}
/**
* @param \Illuminate\Database\Eloquent\Scope[]|string[] $scopes
*/
public static function withoutGlobalScopes( array $scopes = [])
{
if(empty($scopes)) {
static::$globalScopes = [];
} else {
foreach($scopes as $scope) {
static::withoutGlobalScope($scope);
}
}
}
}
The withoutGlobalScope
method mirrors the HasGlobalScopes::addGlobalScope
method to remove a single global scope, and the withoutGlobalScopes
method can accept an array of global scopes to remove or be called with no parameters to remove all global scopes, (the same as Builder::withoutGlobalScopes
).
A drawback?
The fact that we have to do this to remove global scopes for Nova seems to be an oversight. I’m hopeful that withoutGlobalScope
/withoutGlobalScopes
methods will be added to future versions of Laravel. If that happens there will be a method name collision between the HasRemovableGlobalScopes
trait’s methods and the first-party ones, so read the release notes if you’re going to use this method.
Something else that might work
After writing the HasRemovableGlobalScopes
trait I realized it should be possible to create an additional global scope that undoes my original global scope. In this case something like this might work:
Nova::serving(function() {
Event::listen('eloquent.booted: App\Rating', function($rating) {
Rating::addGlobalScope(new AllRatingsScope);
});
});
Where AllRatingsScope
says to include all ratings. I haven’t tried this so don’t know what happens when there are two global scopes that specify opposite conditions. You might get no results, or the scope that’s applied last might win. Assuming that the scope applied last wins it’s still important to use the eloquent.booted event to make sure the “undo” scope is added after the original scope.
All together now
I’m sticking with the HasRemovableGlobalScopes
trait. I feel like actually removing the Global Scope is more logical than adding another scope to undo what the first one does. Using the HasRemovableGlobalScopes
trait, this is what my whole system looks like:
Apply the original global scope to the Rating
, (or wherever you need a global scope), and use the HasRemovableGlobalScopes
trait:
class Rating extends Model
{
use HasRemovableGlobalScopes;
// ...
protected static function boot()
{
parent::boot();
static::addGlobalScope(new ApprovedScope);
}
// ...
}
Set the event listener in the NovaServiceProvider
’s boot method to remove the global scope for Nova:
class NovaServiceProvider extends NovaApplicationServiceProvider
{
/**
* Bootstrap any application services.
*
* @return void
*/
public function boot()
{
Nova::serving(function() {
Event::listen('eloquent.booted: App\Rating', function($rating) {
Log::info('rating booted ' . request()->url());
Rating::withoutGlobalScopes();
});
});
parent::boot();
}
}
It works well for me. A possible enhancement would be to have my own App\Model
class that extends Laravel’s Model
and uses HasRemovableGlobalScopes
, then extend my other models from that. At the moment I don’t have my own App\Model
, and don’t have a lot of places where I need to share between all my models, but if I find I’m sprinkling too many use HasRemovableGlobalScopes
lines around my code base I’ll make the change.