Laravel Gates, Policies, and Response-Based Access Control in Depth
#laravel #authorization #security #saas

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

4 min read Mohamed Said Mohamed Said

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.

Found this useful?

Frequently Asked Questions

3 questions
Q01 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.
Q02 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.
Q03 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.

Continue reading

More Articles

View all