The first authorization bug I ever shipped was a @can('edit', $post) check in a Blade template. The button hid correctly. The endpoint behind it had no check at all. A curious user tried POST /posts/42 and edited a post they could not see. Three hours of incident review later, the fix was one line in the controller. The lesson was bigger: authorization is not a feature you sprinkle on top — it is a decision the system has to be able to answer the same way no matter which surface asks the question.

Laravel's authorization layer — Gates and Policies — is one of the framework's quieter wins, but only if you use it like a single source of truth instead of a bag of helpers.

Gates vs Policies — A Real Distinction

Both Gate and Policy answer the same shape of question: can this user do this thing? The difference is the shape of "this thing":

  • A Gate is a free-floating ability. Gate::define('view-admin-dashboard', ...). It does not correspond to a model. Use Gates for app-level abilities — feature flags, role checks, anything where the answer does not depend on a specific row.
  • A Policy is a class that groups abilities for a specific model. OrderPolicy answers view, create, update, delete for an Order. Use Policies for the 90% of authorization that maps to "can this user act on this row."

The mistake to avoid is using Gates for everything. A Gate::define('update-order', ...) works, but you lose Laravel's auto-discovery, the @can Blade integration, and the policy-method-per-action convention that controllers already understand.

The Seven Default Policy Methods

A Policy generated with --model has seven methods that map to the seven canonical actions on a resource:

Bash
php artisan make:policy OrderPolicy --model=Order
PHP
namespace App\Policies;

use App\Models\Order;
use App\Models\User;

final class OrderPolicy
{
    public function viewAny(User $user): bool
    {
        return true; // anyone signed in can list their own orders
    }

    public function view(User $user, Order $order): bool
    {
        return $order->customer_id === $user->customer_id;
    }

    public function create(User $user): bool
    {
        return $user->hasPermission('orders.create');
    }

    public function update(User $user, Order $order): bool
    {
        return $order->customer_id === $user->customer_id
            && in_array($order->status, ['draft', 'pending'], true);
    }

    public function delete(User $user, Order $order): bool
    {
        return $user->hasRole('admin') || $order->customer_id === $user->customer_id;
    }

    public function restore(User $user, Order $order): bool
    {
        return $user->hasRole('admin');
    }

    public function forceDelete(User $user, Order $order): bool
    {
        return $user->hasRole('admin');
    }
}

Two things hold up well in real codebases. First, those names — view, update, delete — match what your routes are doing, so $this->authorize('update', $order) in the controller does not require any glue. Second, you get to express domain rules where they belong — the "only draft or pending orders can be edited" rule lives in OrderPolicy::update, not in three controllers and two FormRequests.

Auto-discovery wires it up: App\Models\Order looks for App\Policies\OrderPolicy automatically. You can override the convention with Gate::policy(Order::class, MyPolicy::class) in a service provider, but you almost never need to.

A decision-tree diagram of a Laravel authorization check. The top entry is a request asking can-user-update-order. The first node is Gate::before which short-circuits true for super-admin or null to defer. If it defers, the flow goes into OrderPolicy::update where three checks apply in sequence — ownership, role, and order state. Each branch leads to a 200 OK if allowed, or a 403 with AuthorizationException if denied. The right side shows the same decision being asked from four surfaces — controller authorize, FormRequest authorize, Blade can directive, and Eloquent scope — with arrows pointing back to the central policy. Color palette is muted slate blue and warm green.
One policy decision, asked from controller, FormRequest, Blade, and an Eloquent scope

Gate::before And Gate::after — Used Sparingly

Gate::before runs before any policy method and short-circuits the result if it returns a non-null value. It is the right place for global overrides:

PHP
// app/Providers/AuthServiceProvider.php
public function boot(): void
{
    Gate::before(function (User $user, string $ability) {
        if ($user->isSuperAdmin()) {
            return true;
        }
    });
}

The "return null to defer" contract is important — if before returns false, the policy method never runs and the user is denied even if the policy would have allowed them. Use it for "yes" overrides, not "no" overrides.

Gate::after runs after a policy returns and lets you log or monkey-patch the result. I have used it once, for an audit log of admin actions; for most apps it is a tool you will not reach for.

The trap is loading these up with business rules. A Gate::before that lists ten roles with carve-outs is a Policy in the wrong file. Keep before to "is this the user who bypasses everything" and put real logic in policies.

Asking The Question From Four Surfaces

The reason policies are worth using is that the same decision can be asked from anywhere in the framework. The four surfaces that matter:

Controller — authorize() or Gate::authorize()

PHP
public function update(Request $request, Order $order)
{
    $this->authorize('update', $order); // throws AuthorizationException → 403
    // ...
}

FormRequest — the cleanest place to put the check

PHP
public function authorize(): bool
{
    return $this->user()?->can('update', $this->route('order')) ?? false;
}

When a FormRequest's authorize() returns false, Laravel returns a 403 before validation runs. That is the right ordering — "you cannot edit this" is a different bug from "your edit was malformed."

Blade — the @can directive

Blade
@can('update', $order)
    <a href="{{ route('orders.edit', $order) }}">Edit</a>
@endcan

@cannot('delete', $order)
    <span class="text-muted">Cannot delete</span>
@endcannot

The Blade directive calls the same Policy method. Hiding a button is now consistent with whether the endpoint will accept the action.

Eloquent / queries — the part most teams forget

A controller can call authorize('view', $order) after the query, but that does nothing for a list endpoint that loads twenty orders. The fix is a global scope or a policy-aware query helper that filters at the database level:

PHP
final class OrderScope implements Scope
{
    public function apply(Builder $builder, Model $model): void
    {
        if ($user = auth()->user()) {
            if (! $user->isAdmin()) {
                $builder->where('customer_id', $user->customer_id);
            }
        }
    }
}

A policy answers "can this user touch this specific row." A scope answers "which rows can this user even see." Both come from the same domain rule, so keep them in sync — when a policy changes, the scope usually changes with it.

Response::deny() For Better 403s

Returning false from a Policy gives you a generic "This action is unauthorized" response. For UX-sensitive flows, return a Response:

PHP
use Illuminate\Auth\Access\Response;

public function update(User $user, Order $order): Response
{
    if ($order->customer_id !== $user->customer_id) {
        return Response::deny('You do not own this order.');
    }

    if ($order->status === 'shipped') {
        return Response::deny('Shipped orders cannot be edited.', 'order_shipped');
    }

    return Response::allow();
}

The string is the human message; the second argument is a stable code your client can branch on. Response::denyWithStatus(404) is the right choice when "you cannot see this" should look like "this does not exist" — useful for hiding the existence of resources from unauthorized users.

Authorization In Queue Jobs And Console Commands

This is where authorization quietly disappears in most codebases. A queued job runs without an HTTP request, so auth()->user() is null. A console command runs as the system, with no user at all. Two patterns hold up:

PHP
// Pass the actor explicitly
final class ApproveOrderJob implements ShouldQueue
{
    public function __construct(
        public readonly Order $order,
        public readonly User $approver,
    ) {}

    public function handle(): void
    {
        if (! $this->approver->can('update', $this->order)) {
            throw new AuthorizationException('Approver no longer has permission.');
        }
        // ...
    }
}

Or, for system-triggered work, run an unauthenticated path that does not touch policies — an ApproveOrderSystem Action that bypasses policy checks because the system did not need permission. Either way, do not silently lose authorization between HTTP and async; pick a pattern and apply it consistently.

Roles And Permissions Are Not Authorization

Policies answer "is this user allowed to do this." Roles and permissions answer "what does this user generally have access to." They are related but not the same. A role like manager does not by itself decide whether this manager can edit this order — the policy still needs to check ownership, state, and tenant.

The pattern that scales: roles and permissions are inputs to the policy, not a replacement for it.

PHP
public function update(User $user, Order $order): bool
{
    return $user->hasPermission('orders.update')           // role/permission gate
        && $order->customer_id === $user->customer_id      // resource-level rule
        && $order->status !== 'shipped';                   // state rule
}

A package like spatie/laravel-permission is a fine tool for the first part. It is not a replacement for the second and third. The day someone in your manager role tries to edit an order from another tenant, you will be glad the policy was the source of truth.

Five-card authorization checklist arranged around a central OrderPolicy hub with surface labels for Controller, FormRequest, Blade @can, and Eloquent Scope. Card 01 Policy For Rows Gate For Abilities — $user-&gt;can(&#39;update&#39;, $order) for row-level, Gate::allows(&#39;view-admin-dashboard&#39;) for app-level. Card 02 Check At The Edge Not Past It — FormRequest::authorize() runs before validation, controllers call $this-&gt;authorize, Blade uses @can. Card 03 Gate::before For Yes Overrides Only — return true to allow, null to defer, never return false. Card 04 Filter Lists At The Database — OrderScope answers which rows, the policy answers can-touch-this-row, keep them in sync. Card 05 Pass The Actor Into Jobs — auth() is null in queues, dispatch with $approver and re-check inside handle().
One policy as the source of truth for controller, FormRequest, Blade, scope, and queue jobs

Authorization Tests That Pin Down Real Bugs

Two kinds of tests pay rent here. The first is a happy-path / forbidden test for every policy-protected route:

PHP
it('forbids customers from editing other customers orders', function () {
    $userA = User::factory()->create();
    $userB = User::factory()->create();
    $order = Order::factory()->for($userA->customer)->create(['status' => 'pending']);

    actingAs($userB)
        ->putJson(route('orders.update', $order), ['notes' => 'hello'])
        ->assertForbidden();
});

it('allows owners to edit their own pending orders', function () {
    $user  = User::factory()->create();
    $order = Order::factory()->for($user->customer)->create(['status' => 'pending']);

    actingAs($user)
        ->putJson(route('orders.update', $order), ['notes' => 'hello'])
        ->assertOk();
});

The second is a state-transition test for any rule that depends on the entity's state — "shipped orders cannot be edited" is exactly the kind of rule that gets quietly broken six months later by a new endpoint that forgot to check status.

Authorization done properly looks boring on the page. A FormRequest's authorize() calls $user->can(...). A Blade button uses @can. A controller calls $this->authorize(...). A query scope filters the list. The same domain rule, asked four ways, answered the same way every time. That is what the framework was built for — the keystrokes you save by skipping it are the bugs you ship.