Gates and Policies

Gates for standalone rules, policies for a model, the method names Laravel looks for, and authorizing inside a controller.

SyntaxGate::authorize(string $ability, mixed $arguments = []): Illuminate\Auth\Access\Response

Authentication asks who you are. Authorization asks whether you may do this particular thing. Laravel has two tools for it. A gate is a named closure. A policy is a class whose methods are the abilities for one model.

Gates

Define gates in the boot method of App\Providers\AppServiceProvider: Gate::define('view-admin-panel', fn (User $user) => $user->is_admin). The closure's first argument is always the signed-in user, and Laravel passes it in, so you never supply it. Check with Gate::allows, Gate::denies, Gate::any, or Gate::authorize, which throws AuthorizationException and is rendered as a 403.

Policies

php artisan make:policy PostPolicy --model=Post writes app/Policies/PostPolicy.php with methods for viewAny, view, create, update, delete, restore and forceDelete. Every generated method returns false until you edit it.

Nothing registers the policy: discovery is by name. For App\Models\Post, Laravel looks for App\Models\Policies\PostPolicy and then App\Policies\PostPolicy. If your names differ, call Gate::policy(Post::class, PostPolicy::class) in a provider, or put #[UsePolicy(PostPolicy::class)] on the model.

In a controller

Gate::authorize('update', $post) is the whole check. The argument's class picks the policy, the ability name picks the method (a dashed ability like force-delete is camel-cased to forceDelete). $request->user()->can('update', $post) returns a boolean instead. $this->authorize(...) is the same call, but Laravel 11 emptied the base controller, so it needs use AuthorizesRequests; on the class.

Example

Example · php
<?php
// app/Policies/PostPolicy.php
// Generated with: php artisan make:policy PostPolicy --model=Post

declare(strict_types=1);

namespace App\Policies;

use App\Models\Post;
use App\Models\User;
use Illuminate\Auth\Access\Response;

class PostPolicy
{
    /**
     * Runs before every other method in this class. true allows outright,
     * false denies outright, null falls through to the method.
     */
    public function before(User $user, string $ability): bool|null
    {
        return $user->is_admin ? true : null;
    }

    // No model argument: there is no single post to judge.
    public function viewAny(User $user): bool
    {
        return true;
    }

    public function view(User $user, Post $post): bool
    {
        return $post->published_at !== null || $user->id === $post->user_id;
    }

    // Also model-less: nothing exists yet.
    public function create(User $user): bool
    {
        return $user->hasVerifiedEmail();
    }

    /**
     * Returning a Response instead of a bool lets you supply the message
     * that reaches the 403 page.
     */
    public function update(User $user, Post $post): Response
    {
        return $user->id === $post->user_id
            ? Response::allow()
            : Response::deny('You do not own this post.');
    }

    public function delete(User $user, Post $post): bool
    {
        return $user->id === $post->user_id;
    }
}

When to use it

  • Letting an author edit only their own posts while an admin edits any
  • Refusing an action for a whole class of user with one before method
  • Keeping a non-model rule, such as reaching an admin dashboard, out of the controllers
  • Replacing scattered if ($user->role === 'admin') checks with one named ability

More examples

A gate, and three ways to check it

Gates suit rules that belong to no model: a dashboard, an export, a feature switch. allows() flattens a Response back to a boolean, so inspect() is how you read the message without letting the exception fly. Gate::before is the global version of a policy's before method; both are easy to forget when a denial that should happen does not.

Example · php
<?php
// app/Providers/AppServiceProvider.php

declare(strict_types=1);

namespace App\Providers;

use App\Models\User;
use Illuminate\Auth\Access\Response;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        // The user argument is injected; callers never pass it.
        Gate::define('view-admin-panel', function (User $user): Response {
            return $user->is_admin
                ? Response::allow()
                : Response::deny('Staff only.');
        });

        // Applies to every gate and policy in the application.
        Gate::before(function (User $user, string $ability) {
            return $user->is_super_admin ? true : null;
        });
    }
}

// Anywhere in a request:
//
//   if (Gate::allows('view-admin-panel')) { ... }   // bool
//   if (Gate::denies('view-admin-panel')) { ... }   // the opposite
//   Gate::authorize('view-admin-panel');            // or throw -> 403
//
//   $response = Gate::inspect('view-admin-panel');  // the full Response
//   $response->allowed();
//   $response->message();  // 'Staff only.'

Authorizing in a controller

Which policy runs is decided by the argument, which is why abilities without a model take a class string: pass nothing and Laravel has no way to find PostPolicy. $this->authorize('update', $post) is the same call through the Illuminate\Foundation\Auth\Access\AuthorizesRequests trait; Laravel 11 made the generated base controller empty, so add use AuthorizesRequests; to the class or the method does not exist.

Example · php
<?php
// app/Http/Controllers/PostController.php

declare(strict_types=1);

namespace App\Http\Controllers;

use App\Models\Post;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;

class PostController extends Controller
{
    public function update(Request $request, Post $post): RedirectResponse
    {
        // Shorthand: throws AuthorizationException, rendered as 403.
        Gate::authorize('update', $post);

        // The longhand it stands in for:
        // if (! Gate::allows('update', $post)) {
        //     abort(403);
        // }
        //
        // And the same check through the user model:
        // if ($request->user()->cannot('update', $post)) {
        //     abort(403);
        // }

        $post->update($request->validate([
            'title' => ['required', 'string', 'max:255'],
        ]));

        return redirect()->route('posts.show', $post);
    }

    public function store(Request $request): RedirectResponse
    {
        // create() takes no model, so pass the class name instead.
        Gate::authorize('create', Post::class);

        // Extra context: the array's first element picks the policy,
        // the rest are passed on to the method.
        // Gate::authorize('update', [$post, $request->integer('category')]);

        $post = $request->user()->posts()->create($request->validate([
            'title' => ['required', 'string', 'max:255'],
        ]));

        return redirect()->route('posts.show', $post);
    }
}

The rule on its own, as plain logic

Written this way the rule is testable on its own and obviously the same one the policy holds. It also shows the trap in filtering a collection with can(): the check runs once per row, so a list of 200 posts asks the gate 200 times. For a list, put the condition in the query (where('user_id', $user->id)) and keep the policy for the single-record screens.

Example · php
<?php

declare(strict_types=1);

// A policy method is an ordinary predicate. Here it is as a closure
// over fixed arrays, with no database and no container in sight.
$canUpdate = fn (array $user, array $post): bool => $user['id'] === $post['user_id'];

$user = ['id' => 7, 'is_admin' => false];

$posts = collect([
    ['id' => 1, 'user_id' => 7, 'title' => 'Mine'],
    ['id' => 2, 'user_id' => 9, 'title' => 'Someone else'],
    ['id' => 3, 'user_id' => 7, 'title' => 'Also mine'],
]);

$editable = $posts->filter(fn (array $post): bool => $canUpdate($user, $post));

echo $editable->pluck('title')->implode(', '), PHP_EOL;
echo $editable->count(), PHP_EOL;

// Output:
// Mine, Also mine
// 2

Discussion

  • Be the first to comment on this lesson.