Laravel route model binding and global scopes

I have been using Laravel's Global Scopes in a recent project. Global scopes are a great way to ensure you're only returning the data you need, especially if you're using soft deleting or other restrictions such as an "active" state.

Since exploring global scopes, I have found a few pain-points and solutions, including a when multiple models apply the same scope and how to remove scopes from the Nova admin interface.

Recently I have come across another pain-point similar to the Nova problem.

Models and global scopes

I have a scope which restricts the query using the "active" column in the database, which should be set to 1.

namespace App\Scopes;

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

class Active implements Scope
{
    public function apply(Builder $builder, Model $model): void
    {
        $builder->where($model->qualifyColumn('active'), '=', 1);
    }
}

This scope is then applied globally to a model;

namespace App\Models;

use App\Scopes\Active as ActiveScope
use Illuminate\Database\Eloquent\Model;

class Property extends Model
{
    protected static function booted(): void
    {
        static::addGlobalScope(new ActiveScope);
    }
}

Now, whenever I list properties or view a specific property, the application will only return those which have the active state flagged as "1" in the database. This gives the developer confidence that only the data required will be returned.

Route model binding

Most projects I use implicit route model binding. This allows you to build routes which take in to account these global scopes applied to a model in a simple fashion. With the model above, the following route would return a 404 "page not found" if the property you're viewing is not active.

use App\Models\Property;

Route::get('/properties/{property}', function (Property $property) {
    return $property->address;
});

You can also customise the key/field of the binding. The route might find the property using the "slug" field in the database. To do this, you would modify the route to include this key;

use App\Models\Property;

Route::get('/properties/{property:slug}', function (Property $property) {
    return $property->address;
});

Customising the binding

You can customise the logic for resolving the data using the resolveRouteBinding method on the model. This takes the value from the URL and optionally the field you configured in the route. If you don't specify the field, it'll use the getRouteKeyName() method which you can configure on your model. Both of the routes above use the built-in resolution;

public function resolveRouteBinding($value, $field = null)
{
    return $this->where($field ?? $this->getRouteKeyName(), $value)->first();
}

However, I needed to support a more complicated solution. The URL for a property would be a hash, which stops a user guessing incremental identifiers. The database schema I am working with does not include this as a field, so it is calculated from another field. Below is the changed route;

use App\Models\Property;

Route::get('/properties/{property:hash}', function (Property $property) {
    return $property->address;
});

The route binding needed to be updated to support the "hash" key. I created a new method which would be called if this was the case. The resolution defaulted to the underlying logic, so other routes and keys would still be supported.

public function resolveRouteBinding($value, $field = null)
{
	if ($field === 'hash') {
		return $this->resolveRouteBindingByHash($value);
	}

	return parent::resolveRouteBinding($value, $field);
}

The resolveRouteBindingByHash method decrypted the $value and applied the where clause similar to the parent method.

This all worked perfectly and followed the best practices outlined in Laravel's documentation. However, I came unstuck trying to remove the global scope in a specific route.

A problem with administration

Restricting data is great for user-facing projects, however you're likely to build an admin interface to administrate the data. The problem is now only active properties will be shown – this is useless as you may want to make a non-active property active or update a non-active property.

There was a relatively simple to fix to remove all scopes from the Nova admin interface. This could be changed to just remove the specific scopes want to remove using withoutGlobalScope(ActiveScope::class) method.

The problem I came across was with a custom built interface. I was trying to remove the active scope so I could view all properties regardless of their state. I wanted to maintain the custom route binding, so I couldn't use the documented explicit binding which built the query within the callback. I couldn't modify the model as this was also being used on a user-facing interface and needed the global scopes applied to maintain the correct data.

Finding a solution

After stepping through the ImplicitRouteBinding::resolveForRoute() Laravel code, I realised it used the service container to make the model being called in the resolveRouteBinding method. Here I could setup the model without the scope.

Laravel Eloquent models have the HasGlobalScopes trait, which contains the addGlobalScope method we've used above. Unfortunately, there doesn't seem to be the ability to remove them.

In my base model, I added the following method;

public function removeGlobalScope(string $scope): self
{
    Arr::forget(static::$globalScopes[static::class], $scope);

    return $this;
}

Similar to the explicit model binding solution, we can binding against the model. Within a service provider, I added the following. This removes the active scope from the property model anytime the application requests the class.

use App\Models\Property;
use App\Scopes\Active as ActiveScope;

$this->app->bind(Property::class, static function (): Property {
    return (new Property())->removeGlobalScope(ActiveScope::class);
});

It has taken time to understand some underlying core code to getting a working solution. I am not sure this is the best solution, but I haven't found a better method.