Authorization: Gates and Policies

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Laravel 13.x (13.34.0) on PHP 8.3 to 8.5, checked on 2026-10-05. The rules are Bookshelf’s: members borrow and review, librarians edit the catalogue and run the loan desk, admins can do almost everything. Every rule is checked by a Pest test. Authentication answers "who is this?" (Authentication); authorization answers "may they do this?", and Laravel offers two places for the answer: gates, closures for actions that are not about one model, and policies, classes that group the rules for one model.

Roles on the user

Bookshelf stores one role per user in a role column cast to an enum, with a helper on the model:

<?php
// app/Enums/Role.php, and the cast and helper on App\Models\User
namespace App\Enums;

enum Role: string
{
    case Member = 'member';
    case Librarian = 'librarian';
    case Admin = 'admin';
}

// in User::casts(): 'role' => \App\Enums\Role::class,
// public function hasRole(Role ...$roles): bool { return in_array($this->role, $roles, true); }

This is role-based access control (RBAC): permissions follow from a role. The Wiley framework book (ch. 16) set it against access control lists (ACL), where each resource lists who may do what. Laravel policies combine both: a policy method can check a role (hasRole(Role::Librarian)) and the resource itself ($review->user_id === $user->id, "is there an active loan?"), which is what most applications need. Packages such as spatie/laravel-permission add role and permission tables when roles must be managed at run time; they still plug into the same gates.

Gates

Gates are defined in AppServiceProvider::boot(). A gate returns a boolean or a Response with a reason:

<?php
// app/Providers/AppServiceProvider.php, in boot()
use App\Enums\Role;
use App\Models\{Book, User};
use Illuminate\Auth\Access\Response;
use Illuminate\Support\Facades\Gate;

Gate::define('manage-loans', fn (User $user) => $user->hasRole(Role::Librarian, Role::Admin));
Gate::define('borrow', function (User $user, Book $book) {
    return $user->loans()->count() < 3
        ? Response::allow()
        : Response::deny('You already have three books on loan.');
});

Gate::allows() and denies() return booleans, Gate::authorize() throws AuthorizationException (rendered as 403 with the message), and Gate::inspect() returns the Response so the caller can show the reason without an exception. A gate for a guest is never called unless its first parameter is nullable (?User $user): guests are denied by default.

Policies

php artisan make:policy BookPolicy --model=Book creates a class in app/Policies; it is discovered by name (Book → BookPolicy), so no registration is needed. Each method is an ability:

<?php
// app/Policies/BookPolicy.php
namespace App\Policies;

use App\Enums\Role;
use App\Models\Book;
use App\Models\User;
use Illuminate\Auth\Access\Response;

class BookPolicy
{
    public function before(User $user, string $ability): ?bool       // a filter: runs before every method
    {
        if ($ability === 'delete') {
            return null;                                              // the active-loan rule applies to admins too
        }

        return $user->hasRole(Role::Admin) ? true : null;             // null = carry on to the method
    }

    public function viewAny(?User $user): bool                       // ?User: guests are allowed too
    {
        return true;
    }

    public function update(User $user, Book $book): bool
    {
        return $user->hasRole(Role::Librarian);
    }

    public function delete(User $user, Book $book): Response
    {
        if (! $user->hasRole(Role::Librarian, Role::Admin)) {
            return Response::denyAsNotFound();                        // members should not learn the route exists
        }

        return $book->loans()->exists()
            ? Response::deny('A book with active loans cannot be removed.')
            : Response::allow();
    }
}

The if ($ability === 'delete') line was added after a test failed. In the first version before() returned true for admins on every ability, the usual "admins can do anything" pattern. The admin’s delete then bypassed the active-loan check, reached the database, and the restrictOnDelete() foreign key on loans.book_id refused it: a 500 instead of a 403 with a message. A before() filter is right for permissions and wrong for invariants (rules that hold for everyone because the data requires them); those belong in the method, or in the domain code, with the foreign key as the last line of defence. The same kind of filter can be registered for every gate and policy with Gate::before() in a service provider (and Gate::after() to run after the checks), which makes the same mistake easier to miss.

Response::denyAsNotFound() answers 404 instead of 403, so members cannot probe which admin routes exist; Response::denyWithStatus(409) picks any status.

Checking abilities

<?php
// app/Http/Controllers/CatalogueAdminController.php
namespace App\Http\Controllers;

use App\Models\Book;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Attributes\Controllers\Authorize;
use Illuminate\Support\Facades\Gate;

class CatalogueAdminController extends Controller
{
    #[Authorize('update', 'book')]                                     // Laravel 13: the can middleware as an attribute
    public function update(Request $request, Book $book): RedirectResponse
    {
        $book->update($request->validate(['title' => 'required|string|max:200']));

        return back()->with('status', 'Saved');
    }

    public function destroy(Book $book): RedirectResponse
    {
        Gate::authorize('delete', $book);                             // throws AuthorizationException (403/404)
        $book->delete();

        return to_route('books.index');
    }

    public function borrow(Request $request, Book $book): array
    {
        $response = Gate::inspect('borrow', $book);                  // inspect: no exception, a Response object

        return ['allowed' => $response->allowed(), 'message' => $response->message()];
    }
}
<?php
// routes/web.php
use App\Http\Controllers\CatalogueAdminController;
use Illuminate\Support\Facades\Route;

Route::middleware('auth')->prefix('admin/books/{book}')->group(function () {
    Route::patch('/', [CatalogueAdminController::class, 'update']);
    Route::delete('/', [CatalogueAdminController::class, 'destroy']);
    Route::get('/borrow', [CatalogueAdminController::class, 'borrow']);
});
Route::get('/admin/loans', fn () => 'loan desk')->middleware(['auth', 'can:manage-loans']);

The places an ability can be checked, all calling the same gate or policy:

Where How

On the user

$user->can('update', $book), cannot(), canAny([...]); can('create', Book::class) for abilities without an instance

In a controller

Gate::authorize('delete', $book), or #[Authorize('update', 'book')] (Laravel 13), which names the route parameter

On a route

->middleware('can:update,book') or ->can('update', 'book')

In Blade

@can('update', $book) ... @elsecan ... @endcan, @cannot, @canany

In a Form Request

authorize(): bool { return $this->user()->can('update', $this->route('book')); } (Validation and Form Requests)

In Inertia or an API

Compute booleans on the server ('can' => ['update' => $user->can('update', $book)]) and send them as props; the client only hides buttons, the server still checks

The tests:

<?php
// tests/Feature/AuthorizationTest.php
use App\Enums\Role;
use App\Models\{Book, Loan, User};
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Gate;

uses(RefreshDatabase::class);

beforeEach(function () {
    $this->book = Book::factory()->create(['title' => 'Kindred']);
    $this->member = User::factory()->create();
    $this->librarian = User::factory()->create(['role' => Role::Librarian]);
    $this->admin = User::factory()->create(['role' => Role::Admin]);
});

it('answers ability checks on the user and the Gate facade', function () {
    expect($this->member->can('update', $this->book))->toBeFalse()
        ->and($this->librarian->can('update', $this->book))->toBeTrue()
        ->and(Gate::forUser($this->admin)->allows('update', $this->book))->toBeTrue()   // before() filter
        ->and(Gate::forUser(null)->allows('viewAny', Book::class))->toBeTrue()          // guest via ?User
        ->and(Gate::forUser($this->member)->denies('manage-loans'))->toBeTrue();
});

it('enforces #[Authorize] on the controller action', function () {
    $this->actingAs($this->member)->patch("/admin/books/{$this->book->id}", ['title' => 'x'])->assertForbidden();
    $this->actingAs($this->librarian)->patch("/admin/books/{$this->book->id}", ['title' => 'Kindred (2003)'])->assertRedirect();
    expect($this->book->fresh()->title)->toBe('Kindred (2003)');
});

it('returns 404 or a message from policy responses', function () {
    $this->actingAs($this->member)->deleteJson("/admin/books/{$this->book->id}")->assertNotFound();

    Loan::factory()->for($this->book)->create();
    $this->actingAs($this->librarian)->deleteJson("/admin/books/{$this->book->id}")
        ->assertForbidden()->assertJson(['message' => 'A book with active loans cannot be removed.']);

    $this->actingAs($this->admin)->deleteJson("/admin/books/{$this->book->id}")->assertForbidden();  // no bypass here

    $this->book->loans()->delete();
    $this->actingAs($this->admin)->deleteJson("/admin/books/{$this->book->id}")->assertRedirect();
    $this->assertModelMissing($this->book);
});

it('inspects a gate without throwing', function () {
    Loan::factory()->count(3)->for($this->member)->create();

    $this->actingAs($this->member)->getJson("/admin/books/{$this->book->id}/borrow")
        ->assertExactJson(['allowed' => false, 'message' => 'You already have three books on loan.']);
});

it('protects a route with the can middleware', function () {
    $this->actingAs($this->member)->get('/admin/loans')->assertForbidden();
    $this->actingAs($this->librarian)->get('/admin/loans')->assertOk();
});

it('renders @can in Blade', function () {
    $this->actingAs($this->librarian);
    $this->blade('@can("update", $book) <a>Edit</a> @endcan @cannot("delete", $book) no delete @endcannot', ['book' => $this->book])
        ->assertSee('Edit')->assertDontSee('no delete');
});
$ php artisan test --filter=AuthorizationTest
   PASS  Tests\Feature\AuthorizationTest
  ✓ it answers ability checks on the user and the Gate facade            0.24s
  ✓ it enforces #[Authorize] on the controller action                    0.04s
  ✓ it returns 404 or a message from policy responses                    0.03s
  ✓ it inspects a gate without throwing                                  0.02s
  ✓ it protects a route with the can middleware                          0.02s
  ✓ it renders @can in Blade                                             0.02s

  Tests:    6 passed (19 assertions)

Gate::forUser($user) checks on behalf of another user without logging in, which is how tests, queued jobs and admin "impersonation previews" check abilities. A JSON request received the deny message in message; an HTML request gets the 403 error page (Error Handling and Logging).

Extra context, abilities without a model, and hooks

Some rules need more than the user and one model. When a librarian lends a book at the desk, the question is about the borrower as well. Every check (can(), Gate::authorize(), Gate::inspect(), @can) accepts an array as its second argument: the first element picks the policy, and the rest are passed to the method after the model:

<?php
// app/Policies/BookPolicy.php (added; LoanStatus is the loan status enum)
public function lend(User $user, Book $book, User $borrower): Response    // extra context: who borrows
{
    if (! $user->hasRole(Role::Librarian)) {
        return Response::deny('Only the loan desk can lend books.');
    }

    return $borrower->loans()->where('status', LoanStatus::Active)->count() < 3
        ? Response::allow()
        : Response::deny("{$borrower->name} already has three books on loan.");
}

// in the loan desk controller
Gate::authorize('lend', [$book, $borrower]);

The tests also cover a policy method without a model, the global Gate::after() hook, and the inline checks Gate::allowIf() and Gate::denyIf():

<?php
// tests/Feature/AuthorizationContextTest.php (the users are Ben, a member; Lena, a librarian; Ada, an admin)
use Illuminate\Auth\Access\{AuthorizationException, Response};

it('passes extra context to a policy method', function () {
    expect(Gate::forUser($this->librarian)->inspect('lend', [$this->book, $this->member])->allowed())->toBeTrue();

    Loan::factory()->count(3)->for($this->member)->create();

    expect(Gate::forUser($this->librarian)->inspect('lend', [$this->book, $this->member])->message())
        ->toBe('Ben already has three books on loan.')
        ->and(Gate::forUser($this->member)->inspect('lend', [$this->book, $this->member])->message())
        ->toBe('Only the loan desk can lend books.')
        ->and(Gate::forUser($this->admin)->allows('lend', [$this->book, $this->member]))->toBeTrue();   // before()
});

it('checks abilities that need no model instance', function () {
    $unverified = User::factory()->unverified()->create();

    expect($this->member->can('create', Review::class))->toBeTrue()
        ->and($unverified->can('create', Review::class))->toBeFalse()
        ->and($this->librarian->can('create', Book::class))->toBeTrue()
        ->and($this->member->cannot('create', Book::class))->toBeTrue();
});

it('sees every result in Gate::after', function () {
    $seen = [];
    Gate::after(function (?User $user, string $ability, bool|Response|null $result, mixed $arguments) use (&$seen) {
        $outcome = $result instanceof Response ? 'Response('.var_export($result->allowed(), true).')' : var_export($result, true);
        $seen[] = sprintf('%s %s %s', $user?->name ?? 'guest', $ability, $outcome);

        return true;                                    // ignored: the check already has a result
    });

    $this->member->can('update', $this->book);
    $this->admin->can('delete', $this->book);
    Gate::forUser(null)->allows('viewAny', Book::class);

    expect($seen)->toBe(['Ben update false', 'Ada delete Response(true)', 'guest viewAny true'])
        ->and($this->member->can('update', $this->book))->toBeFalse();
});

it('authorizes inline without before or after hooks', function () {
    Gate::before(fn () => true);                        // would allow everything for gates and policies

    $this->actingAs($this->librarian);
    expect(fn () => Gate::allowIf(fn (User $user) => $user->hasRole(Role::Admin)))
        ->toThrow(AuthorizationException::class, 'This action is unauthorized.');
    expect(fn () => Gate::denyIf(fn (User $user) => $user->hasRole(Role::Librarian), 'Librarians use the desk.'))
        ->toThrow(AuthorizationException::class, 'Librarians use the desk.');

    auth()->logout();
    expect(fn () => Gate::allowIf(fn (?User $user) => true))->not->toThrow(AuthorizationException::class)
        ->and(fn () => Gate::allowIf(fn (User $user) => true))->toThrow(AuthorizationException::class);
});
$ php artisan test tests/Feature/AuthorizationContextTest.php
   PASS  Tests\Feature\AuthorizationContextTest
  ✓ it passes extra context to a policy method                          0.41s
  ✓ it checks abilities that need no model instance                     0.03s
  ✓ it sees every result in Gate::after                                 0.03s
  ✓ it authorizes inline without before or after hooks                  0.03s

  Tests:    4 passed (16 assertions)

What the four tests show:

  • Context: the borrower’s three active loans denied the lend with a message naming him, and a member was refused at the desk. The admin was allowed even with the limit reached, because BookPolicy::before() returns true for admins on every ability except delete. If the three-book limit must hold for everyone, it is an invariant like the active-loan rule above, and lend belongs next to delete in that if.

  • Abilities without a model pass the class name (Review::class) so Laravel can find the policy, and ReviewPolicy::create(User $user) receives only the user. The same form works on a route (->can('create', Review::class)) and in Blade (@can('create', App\Models\Review::class)).

  • Gate::after() sees the outcome of every check, for auditing or metrics, and receives null as the user for guests when its first parameter is nullable. The documentation types the third parameter as bool|null, and that signature crashed the first run with a TypeError: a policy method that returns a Response (here delete()) passes the Response object, so the type must be bool|Response|null. The value after() returns only counts when the gate or policy returned null; the true above changed nothing.

  • Gate::allowIf() and Gate::denyIf() check a closure on the spot and throw AuthorizationException (a 403) when it fails. They skip Gate::before() and Gate::after() hooks, which is why the "allow everything" before() did not help the librarian. Without a signed-in user they throw too, unless the closure accepts ?User. They suit one-off checks; anything used in two places is clearer as a gate or policy method, which is also testable by name.

Policy discovery looks for App\Policies\BookPolicy next to App\Models\Book. A policy with another name or namespace is registered with Gate::policy(Book::class, CatalogueBookPolicy::class) in a service provider, or with #[UsePolicy(CatalogueBookPolicy::class)] on the model, and Gate::guessPolicyNamesUsing() replaces the naming rule for the whole application, as described in Manually Registering Policies.

Where older books and tutorials are outdated

  • $this->authorize('update', $book) in controllers (Stauffer ch. 9) relied on the AuthorizesRequests trait of the old base controller; use Gate::authorize() or #[Authorize].

  • Registering every policy in AuthServiceProvider::$policies is replaced by automatic discovery (Laravel 5.8) and #[UsePolicy]; new applications have no AuthServiceProvider (Laravel 11).

  • is_admin checks scattered through controllers (the Wiley book’s role checks in actions) become policy methods and gates, tested in one place.

  • if (! $user->isAdmin()) abort(403) everywhere is Gate::authorize() with a Response that explains the refusal.

  • "Admins can do anything" filters should not skip rules the data depends on, as the delete example showed.