Profile    Mohammed Shiroz Status   Loading  
Logo
Share This
Back to blog
Filter by:
Tags
//Article title

Laravel Policies and Gates: Authorization Done Properly

About Post

Every Laravel codebase I've opened has at least one of these somewhere in a controller:

if ($contract->tenant_id !== auth()->id() && ! auth()->user()->is_admin) {
    abort(403);
}

It works. Then the same check gets copied into the show method, the download method, the API controller and the mobile endpoint. Then someone adds a "leasing staff" role, updates four of the five copies, and the fifth one becomes the bug you hear about from a user instead of from your tests.

Authorization doesn't fail because it's hard. It fails because it's scattered. Laravel's policies and gates exist to put it in one place.

Gates vs policies in one minute

Both answer the same question: "is this user allowed to do this?" The difference is what the question is about.

  • A gate is a single named closure for an action that isn't about one specific model. "Can this user see the financial reports page?"
  • A policy is a class that groups every rule for one model. "Can this user view, update or delete this contract?"

In practice, most rules are about models, so most of your authorization should live in policies. Gates are for the leftovers.

// AppServiceProvider::boot()
Gate::define('view-financial-reports', function (User $user) {
    return $user->hasRole('finance');
});

(hasRole() here and below is your own method on the User model, or one from a roles package.)

A policy per model

Generate one with php artisan make:policy ContractPolicy --model=Contract. If it lives in app/Policies and follows the ModelPolicy naming, Laravel discovers it automatically. No registration needed.

use Illuminate\Auth\Access\Response;

class ContractPolicy
{
    public function view(User $user, Contract $contract): bool
    {
        return $user->id === $contract->tenant_id
            || $user->hasRole('leasing');
    }

    public function update(User $user, Contract $contract): Response
    {
        if ($contract->isSigned()) {
            return Response::deny('Signed contracts cannot be edited.');
        }

        return $user->hasRole('leasing')
            ? Response::allow()
            : Response::deny();
    }
}

Two things worth noticing. Methods can return a plain bool, or a Response when you want a useful message. And business rules ("signed contracts are locked") belong here too, not only role checks. The policy is the one place that answers "can this happen?"

The before() trap

Policies support a before() method that runs ahead of every other check. It's the usual home for "admins can do anything":

public function before(User $user, string $ability): ?bool
{
    return $user->isAdmin() ? true : null;
}

Return true to allow, false to deny, and null to fall through to the normal method. That null matters: return false by accident and you've locked out everyone who isn't an admin.

The bigger trap is subtler. With the before() above, an admin can now edit a signed contract, because before() skips the update() method entirely. If a rule should apply to everyone, including admins, either check for it inside before() or don't use before() for that policy.

A practical rule: use before() for "who you are" shortcuts, never for skipping "what state the record is in". Locked, signed, archived and paid usually mean locked for everyone.

Where to call it

There are several ways to trigger a policy. Pick one per layer and be consistent.

In controllers, use Gate::authorize(). It throws a 403 (with your message) when the check fails:

public function update(UpdateContractRequest $request, Contract $contract)
{
    Gate::authorize('update', $contract);

    $contract->update($request->validated());

    return to_route('contracts.show', $contract);
}

A gotcha if you learned Laravel a few versions ago: since Laravel 11, the default base Controller is empty, so $this->authorize() isn't there unless you add the AuthorizesRequests trait yourself. Gate::authorize() works everywhere without it.

On routes, the can method is great for simple cases: Route::put('/contracts/{contract}', ...)->can('update', 'contract');

For actions without a model instance, like create or viewAny, pass the class name: Gate::authorize('create', Contract::class).

In Blade, hide what the user can't use:

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

Hiding the button is a courtesy, not security. The controller check is the real lock. The Blade check just stops you from showing doors people can't open.

What policies don't do: filter lists

This one surprises people. viewAny answers "can this user see the contracts index at all?" It does not decide which contracts appear in it. If your index runs Contract::paginate(), every user sees every contract, policy or not.

For lists, use a query scope that mirrors the policy's logic, for example Contract::visibleTo($user)->paginate(). Keep the two side by side in your head (and in your tests), because they need to agree.

403 or 404?

A 403 confirms the record exists. Sometimes that's fine. Sometimes it leaks information, like which IDs are valid. When it matters, return Response::denyAsNotFound() from the policy and the user gets a 404 instead.

Test the rules, not just the pages

Authorization bugs are rarely about the happy path. They're about the user who shouldn't get in. So write the "should be blocked" tests first:

public function test_tenant_cannot_edit_another_tenants_contract(): void
{
    $contract = Contract::factory()->create();
    $otherTenant = User::factory()->tenant()->create();

    $this->actingAs($otherTenant)
        ->put(route('contracts.update', $contract), ['notes' => 'hi'])
        ->assertForbidden();
}

For the policy itself, quick checks like $this->assertFalse($user->can('update', $signedContract)) run fast and cover every role and state combination without HTTP. Once you have several roles, as with the 50+ staff users of Kate PMS, a data-driven test that walks a table of "role × action × expected result" becomes the clearest documentation of who can do what.

The checklist

  • One policy per model; gates only for non-model actions.
  • Business state rules (signed, paid, archived) live in the policy too.
  • before() returns null to fall through, and never skips state rules.
  • Gate::authorize() in controllers, @can in views for the UI.
  • Lists are filtered by a query scope, not by viewAny.
  • Tests for the users who should be denied.

What's the strangest authorization rule you've had to implement? I'm always curious which ones end up living outside the policy, and why.

Comments (0)
Leave your review

Thanks for your valuable comments. Your comments has been updated and appreciate your getting in touch...

01. About Shiroz

Mohammed Shiroz

Hi, I'm Mohammed Shiroz, a software engineer and AI enthusiast from Sri Lanka who turns ideas into intelligent, real-world solutions. With over 9 years of hands-on experience, I currently lead real estate ERP development at Kate Group, a...

03.My Projects

04. Categories

Ready To order Your Project ?

Get in Touch
Close