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
Responseobjects instead of booleans to carry denial messages and HTTP codes. Gate::inspect()retrieves theResponsewithout throwing — ideal for logging.beforein a policy is for super-admin bypass;Gate::beforeis 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.