Beyond true and false: Response-Based Authorization
Most Laravel codebases use policies that return a plain boolean. That works until a consumer — a Filament panel, a REST client, or a CLI command — needs to know why access was denied. Laravel's Illuminate\Auth\Access\Response class solves this without coupling your domain to HTTP.
use Illuminate\Auth\Access\Response;
class DocumentPolicy
{
public function update(User $user, Document $document): Response
{
if ($document->isLocked()) {
return Response::deny('Document is locked for editing.', 'DOCUMENT_LOCKED');
}
if ($user->cannot('edit-documents')) {
return Response::deny('Insufficient permissions.', 'PERMISSION_DENIED');
}
return Response::allow();
}
}
The second argument to deny() is a machine-readable code — perfect for API clients that need to branch on denial reason without parsing human strings.
Surfacing the Response in an API
When you call $this->authorize('update', $document) in a controller, Laravel throws AuthorizationException on denial. You can catch it and expose the structured response:
use Illuminate\Auth\Access\AuthorizationException;
public function update(Request $request, Document $document): JsonResponse
{
try {
$this->authorize('update', $document);
} catch (AuthorizationException $e) {
return response()->json([
'message' => $e->getMessage(),
'code' => $e->response()?->code(),
], 403);
}
// ...
}
Or register a global handler in bootstrap/app.php (Laravel 11+):
->withExceptions(function (Exceptions $exceptions) {
$exceptions->render(function (AuthorizationException $e) {
return response()->json([
'message' => $e->getMessage(),
'code' => $e->response()?->code() ?? 'FORBIDDEN',
], 403);
});
})
Gate Hooks: before and after
Gate hooks let you intercept every authorization check without touching individual policies — ideal for super-admin bypass or audit logging.
// AppServiceProvider::boot()
Gate::before(function (User $user, string $ability): ?bool {
if ($user->hasRole('super-admin')) {
return true; // short-circuit all checks
}
return null; // continue normal evaluation
});
Gate::after(function (User $user, string $ability, bool|Response $result, mixed $arguments): void {
AuditLog::record($user->id, $ability, $result instanceof Response ? $result->allowed() : $result);
});
Return null from before to fall through to the policy. Return true or false to short-circuit. The after hook receives the final result but cannot override it — it is purely observational.
Composing Policies with Shared Logic
Avoid copy-pasting ownership checks across policies by extracting a reusable concern:
trait EnforcesOwnership
{
protected function ownedBy(User $user, mixed $model): Response
{
return $user->id === $model->user_id
? Response::allow()
: Response::deny('You do not own this resource.', 'NOT_OWNER');
}
}
class CommentPolicy
{
use EnforcesOwnership;
public function delete(User $user, Comment $comment): Response
{
return $this->ownedBy($user, $comment);
}
}
Authorizing Without a User (Guest Policies)
Policies receive a nullable User by default only if you type-hint ?User. This lets you allow read access to guests explicitly:
public function view(?User $user, Document $document): Response
{
if ($document->isPublic()) {
return Response::allow();
}
return $user
? Response::allow()
: Response::deny('Login required.', 'UNAUTHENTICATED');
}
Without the ? nullable hint, Laravel skips the policy entirely for unauthenticated requests and denies by default.
Key Takeaways
- Use
Response::deny($message, $code)to give API clients actionable denial reasons. Gate::beforeis the right place for super-admin bypass — keep it out of individual policies.Gate::afteris audit-only; it cannot change the authorization outcome.- Traits are a clean way to share ownership or role checks across multiple policies.
- Nullable
?Usertype hints are required to handle guest authorization explicitly in policies.