Skip to content

Laravel Gates, Policies, and Response-Based Access Control in Depth

Move beyond simple boolean gates. Learn how Laravel's Response objects, before hooks, policy filters, and inline gates compose into a maintainable, auditable authorization layer for complex SaaS apps.

Published · Updated · 4 min read

Laravel Gates, Policies, and Response-Based Access Control in Depth

Beyond can(): Building a Real Authorization Layer

Most Laravel tutorials stop at $user->can('update', $post). In production SaaS apps, authorization is one of the most load-bearing parts of the codebase. Get it wrong and you leak data; get it messy and you can't audit it. This article covers the patterns that hold up at scale.


Gates vs. Policies: When to Use Each

Gates are closures registered in a service provider — ideal for actions not tied to a specific model (view-dashboard, access-billing). Policies are classes that group model-scoped abilities and benefit from auto-discovery.

The rule of thumb: if there's an Eloquent model involved, use a policy. Everything else is a gate.

// AppServiceProvider::boot()
Gate::define('access-billing', function (User $user): bool {
    return $user->subscription()->active();
});

Response Objects: Richer Denials

Boolean gates lose context. Response objects let you attach a human-readable message and an HTTP status code — invaluable for API consumers and audit logs.

Gate::define('delete-workspace', function (User $user, Workspace $workspace): Response {
    if ($workspace->owner_id === $user->id) {
        return Response::allow();
    }

    if ($workspace->members()->where('user_id', $user->id)->exists()) {
        return Response::deny('Members cannot delete a workspace.', 403);
    }

    return Response::denyWithStatus(404); // hide existence from outsiders
});

Call Gate::inspect('delete-workspace', $workspace) to get the Response object directly — great for logging the denial reason without throwing.

$response = Gate::inspect('delete-workspace', $workspace);

if ($response->denied()) {
    Log::warning('Authorization denied', [
        'user'    => $user->id,
        'ability' => 'delete-workspace',
        'reason'  => $response->message(),
    ]);
}

Policy before and after Hooks

before runs ahead of every policy method. Use it for super-admin bypass — but be deliberate: returning null falls through to the real check, while returning true short-circuits everything.

public function before(User $user, string $ability): ?bool
{
    if ($user->hasRole('super-admin')) {
        return true; // bypass all checks
    }

    return null; // continue to the specific method
}

after receives the result of the policy method and can override it — useful for injecting a global read-only mode without touching every method.

public function after(User $user, string $ability, bool $result): ?bool
{
    if (app('maintenance')->readOnly() && str_starts_with($ability, 'create')) {
        return false;
    }

    return null;
}

Policy Filters at the Gate Level

For cross-cutting concerns (e.g., impersonation, tenant isolation), register a Gate::before callback in your service provider rather than duplicating logic across every policy.

Gate::before(function (User $user, string $ability): ?bool {
    // Impersonation: the impersonator inherits the impersonated user's permissions
    if (session()->has('impersonating')) {
        $real = User::find(session('impersonating'));
        return $real?->can($ability) ? null : false;
    }

    return null;
});

Scoping Policies to Tenants

In a multi-tenant app, every policy method should verify the model belongs to the current tenant before checking the user's role within that tenant.

public function update(User $user, Project $project): Response
{
    if ($project->team_id !== $user->current_team_id) {
        return Response::denyWithStatus(404);
    }

    return $user->teamRole($project->team_id) === 'editor'
        ? Response::allow()
        : Response::deny('Editors only.', 403);
}

Returning 404 instead of 403 prevents resource enumeration — a small but meaningful security detail.


Testing Authorization

Pest makes policy assertions concise:

it('denies non-owners from deleting a workspace', function () {
    $owner  = User::factory()->create();
    $member = User::factory()->create();
    $ws     = Workspace::factory()->for($owner, 'owner')->create();
    $ws->members()->attach($member);

    expect($member->cannot('delete', $ws))->toBeTrue();

    $response = Gate::forUser($member)->inspect('delete', $ws);
    expect($response->message())->toBe('Members cannot delete a workspace.');
});

Key Takeaways

  • Use Response objects instead of booleans to carry denial messages and HTTP codes.
  • Gate::inspect() retrieves the Response without throwing — ideal for logging.
  • before in a policy is for super-admin bypass; Gate::before is for cross-cutting tenant/impersonation logic.
  • Return 404 responses when a user shouldn't know a resource exists.
  • Test both the boolean outcome and the denial message to lock down authorization contracts.

Frequently asked questions

When should I use Gate::inspect() instead of Gate::allows()?

Use Gate::inspect() when you need the denial reason — for logging, API error responses, or audit trails. Gate::allows() returns a plain boolean and discards the message.

Does returning null from a policy before() method skip the check entirely?

No. Returning null from before() tells Laravel to continue to the specific policy method. Only returning true or false short-circuits further evaluation.

Why return a 404 response from a policy instead of 403?

Returning 404 prevents resource enumeration: an attacker cannot distinguish between 'this resource doesn't exist' and 'you don't have access to it', reducing information leakage.

Mohamed Said

About the author

Senior Backend Engineer specializing in Laravel, scalable SaaS platforms, APIs, and cloud infrastructure. I build secure, high-performance web applications that help businesses grow.

About GitHub LinkedIn WhatsApp Email Address My CV

Have a technical challenge?

Tell me what you’re building. I reply within two working days.

Discuss your project