Policies or Gates: Where Authorisation Should Live in Laravel

Policies or Gates: Where Authorisation Should Live in Laravel

October 5, 2026
Four Laravel authorisation mechanisms and the question each one answers.

Authorisation starts as one line in a controller. Somebody adds a check that the user owns the invoice, it works, everyone moves on.

Eighteen months later the same rule exists in the controller, in the Blade template that decides whether to show the button, in the API resource that decides whether to include a field, in a queue job, and in an export command. Four of those five agree. The fifth is the bug.

Laravel gives you four mechanisms for this and does not strongly say which to use where, so most codebases use all four inconsistently. The useful question is not which is best — it is which question each one answers.

Four Laravel authorisation mechanisms and the question each one answers.
Most codebases use all four inconsistently — the useful question is which one answers which question

The distinction that decides everything

Every authorisation check is one of two questions, and they want different tools.

“Can this user do this kind of thing at all?” Is this person an admin. Do they have the billing role. Is their subscription active. No particular record is involved.

“Can this user do this to that record?” Can they edit invoice 4102. Can they delete this project. The answer depends on the row.

Gates answer the first. Policies answer the second. Everything else is delivery.

// Gate: no model in sight
Gate::define('view-billing', fn (User $u) => $u->hasRole('owner'));

// Policy: the model is the whole point
class InvoicePolicy
{
    public function update(User $user, Invoice $invoice): bool
    {
        return $user->organization_id === $invoice->organization_id
            && $user->hasRole(['owner', 'admin']);
    }
}

When a rule is about a model, put it in that model’s policy even if it does not currently need the model. Rules acquire record-specific conditions over time, and a gate that grows one has to be moved and every call site changed.

Gates for capabilities, policies for records. The moment a rule mentions a row, it belongs in a policy.

One rule, one place, several ways to ask

The value of a policy is not the syntax. It is that the rule exists once and every layer asks the same object.

// Controller — throws 403
$this->authorize('update', $invoice);

// Controller, returning a boolean instead
if ($request->user()->can('update', $invoice)) { ... }

// Blade — hide the button
@can('update', $invoice)
    <button>Edit</button>
@endcan

// API resource — include a field conditionally
'total' => $this->when($request->user()->can('viewFinancials', $this->resource), $this->total),

// Anywhere with no request, including a queue job
Gate::forUser($user)->allows('update', $invoice);

That last one matters more than it looks. Authorisation written as auth()->user()->isAdmin() inside a controller cannot be reused by a job, because a job has no authenticated user. A policy takes the user as an argument, so the same rule works from a command, a listener or a test.

Resource controllers, and the check you did not write

authorizeResource wires a policy to a controller’s seven standard methods in one line.

public function __construct()
{
    $this->authorizeResource(Invoice::class, 'invoice');
}

It maps index to viewAny, show to view, store to create, update to update, destroy to delete. Every method is covered without a line inside it.

Two traps. Methods you add yourself — duplicate, send, markPaid — are not covered, and they are exactly the methods that do something significant. And the route parameter name has to match the second argument, or the model never reaches the policy and the check silently passes a null.

public function send(Invoice $invoice)
{
    $this->authorize('send', $invoice);   // authorizeResource does not do this one
    ...
}

When there is no model yet

Two policy methods are always awkward because there is no instance to check.

create receives only the user, because the record does not exist. viewAny is the same, for index pages.

public function create(User $user): bool
{
    // Plan limits belong here as much as roles do
    return $user->hasRole(['owner', 'admin'])
        && $user->organization->invoices()->count() < $user->organization->plan->invoice_limit;
}

public function viewAny(User $user): bool
{
    return $user->hasRole(['owner', 'admin', 'manager']);
}

Note what viewAny does not do: it does not filter the list. A user who may view invoices still needs the query scoped to their organisation, and no policy method will do that for you. Authorisation says whether the page may load; scoping says which rows it contains. Conflating them is how a correctly-authorised page shows another tenant’s data.

Authorisation and scoping as two different jobs that get conflated.
A correctly authorised page can still show another tenant's rows

Where middleware earns its place

Middleware is for what must be true before the controller runs at all, and for rules that apply to whole groups of routes.

Route::middleware(['auth', 'verified', 'subscription.active'])->group(function () {
    Route::resource('invoices', InvoiceController::class);
});

// Gate as middleware, for a capability rather than a record
Route::middleware('can:view-billing')->get('/billing', BillingController::class);

// Policy as middleware, with the route model
Route::middleware('can:update,invoice')->put('/invoices/{invoice}', ...);

What middleware should not hold is per-record logic. A middleware that loads a model and checks ownership duplicates a policy in a place nobody looks, and it cannot be reused by the Blade template that wants to know whether to show the button.

The split that stays clean: middleware answers “should this request proceed”, policies answer “may this user act on this record”.

Form requests: the same rule, earlier

A form request runs its authorize() before validation, and returning false gives a 403 with no validation performed — which is correct, because a user who may not act on a record should not be told which of their fields were invalid.

class UpdateInvoiceRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user()->can('update', $this->route('invoice'));
    }

    public function rules(): array { return ['total' => ['required', 'numeric']]; }
}

Delegating to the policy rather than restating the rule is the whole point. A form request that contains its own copy of the ownership check is the fifth place the rule lives.

The default authorize() returns true on a generated form request, which means an unedited one authorises everything. That is a reasonable default and a common oversight — worth grepping for.

The admin override, and where it goes wrong

Every application eventually needs a role that bypasses the rules. Gate::before is the sanctioned way.

Gate::before(function (User $user, string $ability) {
    if ($user->isSuperAdmin()) {
        return true;   // short-circuits every gate and policy
    }
});

It short-circuits everything, including policies you have not written yet, which is both its value and its danger. Returning true here means the super admin passes every future check by default rather than by decision.

Two details worth getting right. Return null rather than false when the condition does not apply — returning false denies the ability outright and stops the policy running at all, which produces a baffling bug where adding an admin check breaks normal users.

And exempt the abilities that should never be bypassed. Deleting an organisation, exporting everyone’s data, changing billing — those deserve a check that a super admin also has to pass.

Gate::before(function (User $user, string $ability) {
    if (in_array($ability, ['deleteOrganization', 'exportAllData'], true)) {
        return null;      // no bypass: fall through to the policy
    }
    return $user->isSuperAdmin() ? true : null;
});

Responses that leak the answer

A correct 403 can still tell an attacker something, and the leak is in the difference between two of them.

Ask for invoice 4102 belonging to another organisation. If the response is 403, you have learned that invoice 4102 exists. If you ask for 99999 and get 404, you have learned it does not. Walk the range and you have the size of the customer base.

For most business applications that is an acceptable leak and not worth complicating the code for. Where it is not — a medical record, a legal case, anything where existence itself is sensitive — the answer is to return 404 for both cases.

// Route model binding scoped to the tenant: a row that is not yours 404s,
// because as far as this query is concerned it does not exist.
Route::bind('invoice', function (string $id) {
    return Invoice::where('organization_id', auth()->user()->organization_id)
        ->findOrFail($id);
});

That also removes an entire category of mistake: the model reaching a controller before anybody checked whether the user should see it. With a scoped binding, an out-of-tenant id never becomes a model at all, and a forgotten authorize call fails closed rather than open.

It is not a replacement for policies — it says nothing about whether this user may edit the invoice they can legitimately see — but it is a good floor underneath them.

Roles, permissions, and the table nobody needed

The instinct when authorisation grows is to reach for a roles-and-permissions package with four tables and a UI.

Sometimes that is right. More often the application has four roles that have not changed in two years, and the package adds a join to every request plus a concept nobody uses.

// Enough for most applications, and readable
enum Role: string
{
    case Owner   = 'owner';
    case Admin   = 'admin';
    case Manager = 'manager';
    case Member  = 'member';

    public function canManageBilling(): bool
    {
        return $this === self::Owner;
    }
}

A column on the users table, an enum, and the policies asking it. No extra queries, the whole model visible in one file, and a typo is a compile-time error rather than a string that silently matches nothing.

The signal that you have outgrown it is real and specific: customers asking to define their own roles. Until somebody asks for that, a database-backed permission system is a lot of machinery for a list that lives in one file.

The migration path is also easier than the reverse. Going from an enum to a table is a data migration. Going from a table back to an enum, after the UI has let customers create seventeen bespoke roles, is a negotiation.

Testing that a route is actually protected

Policy unit tests are easy and they test the rule, not the wiring. The failure that reaches production is a route where nobody called authorize at all, and a policy test cannot see that.

public function test_a_user_cannot_update_another_organisations_invoice(): void
{
    $mine   = User::factory()->create();
    $theirs = Invoice::factory()->create();   // different organisation

    $this->actingAs($mine)
        ->putJson("/api/invoices/{$theirs->id}", ['total' => 1])
        ->assertForbidden();
}

Write that through the HTTP layer, once per destructive route. It is the test that catches the controller method somebody added last week without the authorise call — the thing a policy test structurally cannot find.

For a systematic sweep, iterate the route list and assert every non-public route rejects a user from another organisation:

public function test_no_route_is_left_unprotected(): void
{
    $outsider = User::factory()->create();
    $record   = Invoice::factory()->create();     // belongs to someone else

    foreach (Route::getRoutes() as $route) {
        if (!str_starts_with($route->uri(), 'api/invoices/')) { continue; }
        if (in_array('GET', $route->methods(), true)) { continue; }

        $uri = str_replace('{invoice}', $record->id, $route->uri());
        $this->actingAs($outsider)
            ->call($route->methods()[0], "/{$uri}")
            ->assertStatus(403, "Unprotected: {$route->uri()}");
    }
}

It is crude and it fails loudly the first time somebody adds a route and forgets, which is the entire job.

The test that catches an unprotected route, which policy unit tests cannot.
The rules are usually right. The missing authorize() call is what ships.

Authorisation in a queue job, and the user who left

A job runs without a request, so auth()->user() is null and any rule written against it silently evaluates as unauthenticated. That produces two opposite bugs depending on how the check was written, and both are bad.

The rule that always works takes the user explicitly:

class SendInvoice implements ShouldQueue
{
    public function __construct(
        public Invoice $invoice,
        public int $actorId,          // who asked for this
    ) {}

    public function handle(): void
    {
        $actor = User::find($this->actorId);

        // The user may have been deactivated between queueing and running
        abort_unless($actor && Gate::forUser($actor)->allows('send', $this->invoice), 403);
        ...
    }
}

That re-check matters more than it looks. A job queued on Monday and retried on Wednesday runs with Monday’s intent and Wednesday’s permissions, and the gap is where an offboarded employee’s queued export still runs.

Whether to re-check is a genuine decision rather than an obvious one. For a report, re-checking is right — the person should not receive data they may no longer see. For an audit log entry or a webhook the user triggered, re-checking is wrong, because the action was already authorised when it was requested and failing it now loses a record of something that happened.

Ask whether the job completes an action already authorised, or performs a new one. The first should not re-check; the second must.

The N+1 hiding inside a policy

A policy runs once per object, which is fine until the object is a row in a list of two hundred.

@foreach ($invoices as $invoice)
    @can('update', $invoice)
        <a href="...">Edit</a>
    @endcan
@endforeach

If the policy reads $invoice->project->owner_id, that is two hundred project queries and possibly two hundred user queries, generated by the authorisation layer rather than by anything visible in the controller. It does not show up in a page that lists five records during development and appears as a slow page in production.

Two fixes, and the first is usually enough:

$invoices = Invoice::with('project.owner')->paginate(50);   // eager load what the policy touches

The second is to write the policy so it does not need the relation at all. Denormalising organization_id onto the invoice — which the multi-tenant scope usually wants anyway — turns the check into a comparison against a column that is already loaded.

The general rule: a policy that traverses a relation puts a query behind every permission check, so either preload the path or flatten it. Whichever you pick, it is worth adding a test that asserts a query count on the index route, because this regression comes back every time somebody adds a condition to the policy.

Gates in Blade, and the ones that lie

@can hides a button; it does not protect anything. That has to be said plainly because it is the most common misunderstanding in a codebase that uses gates well elsewhere.

@can('delete', $invoice)
    <button>Delete</button>
@endcan

Hiding the button is a courtesy to the user. The controller still has to authorise, because a POST does not care what the page rendered. Every route needs its own check even when the UI could never produce the request, and a codebase where the only check is in the template has no authorisation at all — just a tidy interface over an open endpoint.

The inverse mistake is subtler: a view that checks a different ability than the controller enforces. The button says the user may act, the request comes back 403, and the bug report is “the delete button does not work”. Naming the ability once and using that same string in both places is the whole fix.

What we settled on

Gates for capabilities that have no record — view billing, manage plans, impersonate a member. There are about six of them and they live in one provider.

Policies for everything that names a model, including the rules that do not currently need the instance, because they eventually will.

Middleware only for request-level facts: authenticated, verified, subscription active. No per-record logic, ever.

Form requests delegate to the policy rather than restating it.

And one HTTP test per destructive route asserting that an outsider gets a 403. That last one has caught more real problems than every unit test of the policies themselves, because the rules were usually right and the wiring was what somebody forgot.