Beyond can(): Authorization That Explains Itself
Most Laravel applications use $user->can('update', $post) and call it done. That boolean is fine for simple guards, but production systems need richer feedback: why was access denied, which role was missing, or whether the resource even exists. Laravel's authorization layer already supports this — most teams just never reach for it.
Gate Responses vs. Booleans
A Gate closure can return an Illuminate\Auth\Access\Response instead of a plain boolean:
use Illuminate\Auth\Access\Response;
Gate::define('publish-post', function (User $user, Post $post): Response {
if ($post->author_id !== $user->id) {
return Response::deny('You do not own this post.', 'post.not_owner');
}
if (! $user->hasVerifiedEmail()) {
return Response::deny('Verify your email before publishing.', 'user.unverified');
}
return Response::allow();
});
The second argument to deny() is a machine-readable code your API can forward to the client. Retrieve it with Gate::inspect():
$response = Gate::inspect('publish-post', $post);
if ($response->denied()) {
return response()->json([
'message' => $response->message(),
'code' => $response->code(),
], 403);
}
This pattern keeps authorization logic out of controllers and gives API consumers actionable error codes without leaking internals.
Policy Responses and HTTP Status Codes
Policies support the same Response objects. You can also control the HTTP status code returned when authorize() throws:
public function delete(User $user, Post $post): Response
{
if ($post->trashed()) {
return Response::denyWithStatus(404); // hides existence from unauthorized users
}
return $user->id === $post->author_id
? Response::allow()
: Response::denyAsNotFound(); // shorthand for 404
}
denyAsNotFound() is invaluable for multi-tenant systems where leaking a resource's existence is itself a security flaw.
The before Hook: Super-Admin Without Polluting Every Policy
Avoid sprinkling $user->isAdmin() across every policy method. Register a single before hook on the Gate:
// AppServiceProvider::boot()
Gate::before(function (User $user, string $ability): ?bool {
if ($user->hasRole('super-admin')) {
return true; // short-circuits all further checks
}
return null; // continue normal evaluation
});
Return null (not false) to pass control to the next check. Returning false would deny the ability unconditionally, which is rarely what you want in a before hook.
Guest Authorization
By default, unauthenticated users never reach a gate or policy. Opt in per method with a nullable type hint:
public function view(?User $user, Post $post): bool
{
if ($post->is_public) {
return true; // guests can view public posts
}
return $user?->id === $post->author_id;
}
This avoids a separate middleware layer for mixed public/private resources.
Testing Authorization with Pest
it('denies publishing when email is unverified', function () {
$user = User::factory()->unverified()->create();
$post = Post::factory()->for($user, 'author')->create();
$response = Gate::forUser($user)->inspect('publish-post', $post);
expect($response->denied())->toBeTrue()
->and($response->code())->toBe('user.unverified');
});
it('returns 404 when unauthorized user probes a private post', function () {
$attacker = User::factory()->create();
$post = Post::factory()->create();
actingAs($attacker)
->delete("/posts/{$post->id}")
->assertNotFound();
});
Gate::forUser() lets you test any user's permissions without touching session state.
Key Takeaways
- Return
Response::deny($message, $code)from gates and policies to give API consumers machine-readable denial reasons. - Use
Gate::inspect()in controllers to access the full response object rather than catching exceptions. denyAsNotFound()/denyWithStatus(404)prevents resource enumeration in multi-tenant or private-resource APIs.- The
Gate::before()hook is the correct place for super-admin bypass — keep individual policies clean. - Nullable
?Usertype hints opt a policy method into guest evaluation without extra middleware. - Test with
Gate::forUser()->inspect()to assert on denial codes, not just HTTP status codes.