Get started with 33% off your first certification using code: 33OFFNEW

Disable global scopes on individual routes with route binding in Laravel

4 min read
Published on 3rd April 2023
Disable global scopes on individual routes with route binding in Laravel

In Laravel global scopes are extremely useful. Global scopes allow you to add segments to every query that is run on a model.

Let's take a look at the following example:

class Post extends Model
{
    protected static function booted()
    {
        static::addGlobalScope('published', function (Builder $builder) {
            $builder->where('published_at', '<=', now());
        });
    }
}

If this isn't obvious it adds a global scope that ensures all posts have a published_at datetime/timestamp in the past. This means you don't have to worry about checking this date all over your app when pulling posts.

Global scopes like this are very popular in Laravel, and they're very scalable as you can easily add functionality such as the published flag above without having to refactor lots of parts of your app.

The problem: admin routes with route bindings

Keeping the same example of posts, you probably have an area of your application where you're able to add, edit and delete posts as an admin.

If you're using route bindings then this causes some problems.

What are route bindings?

Route bindings are a feature of Laravel that allows the framework to look up a model and 404 if it can't be found. You can specify the model and the field to lookup as part of the URI in the route.

Same example flow as above, here's how an admin route binding might look:

Route::middleware(['auth', 'admin'])->name('admin.')->prefix('admin')->group(function () {
    Route::get('/blogs/{post:id}/edit', [AdminBlogsController::class, 'edit'])->name('blogs.edit');
    Route::post('/blogs/{post:id}/update', [AdminBlogsController::class, 'update'])->name('blogs.update');
});

This defines the following URIs:

/admin/blogs/1/edit
/admin/blogs/1/update

Where 1 is the ID of the blog. The problem is that as soon as you introduce the global scope then any unpublished posts won't be editable by the admin. This obviously won't work for us, so how do we handle it?

Option 1: Introduce logic into your global scope

In our above example we don't want admins to have the global scope to be applied. One fix for this is to still apply the global scope, but add some logic to global scope itself to check if its an admin.

// Post.php

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;

// [...]

protected static function booted(): void
{
    static::addGlobalScope('published', function (Builder $builder) {
        if(!Auth::check() || (Auth::check() && !Auth::user()->isSuperAdmin()) ) {
            $builder->where('published_at', '<=', now());
        }
    });
}

Here we've added an if-statement that only applies the Builder rule/query if the user isn't logged in or they are logged in but aren't an admin.

This approach has a few advantages. Because it globally removes the rule for admins (eg not just a specific route) then it opens up other routes for access, such as the ability to view posts that haven't yet been published on the frontend of our app. In our use case that's a definite bonus, but you should understand the implications of this in your app.

Option 2: Named route bindings

Route binding happens based on the name of the field and model that you pass through. For example, the following route definition:

Route::get('/blogs/{post:id}/edit', [AdminBlogsController::class, 'edit'])->name('blogs.edit');

Is looking at the Post model on the id column/field. Laravel's under-the-hood magic takes care of this for us, however it's possible for us to define our own route bindings.

Take the following route definition:

Route::get('/blogs/{admin_blog}/edit', [AdminBlogsController::class, 'edit'])->name('blogs.edit');

This allows use to attach a rule to admin_blog routes anywhere they're used.

Add the following to your RouteServiceProvider.php file:

public function boot()
{
    parent::boot();

    Route::bind('admin_blog', function($id) {
        return \App\Model\Post::withoutGlobalScopes()->findOrFail($id);
    });
}

This will intercept any route with an admin_blog segment and run that query for it. This gives much more flexibility than option #1, but if this is an existing app then you'll need to make changes to anywhere you link to the route already.

route('admin.blogs.edit', ['blog' => $blog]);

will need to become:

route('admin.blogs.edit', ['admin_blog' => $blog]);

It's a small change, but if you have a large app this could become problematic.

Other options

You do have other options but they depend heavily on how your app has been put together. Other options include removing the global scope in middleware, or holding the logic in a service, or inside your controllers (although we wouldn't recommend this).