Beyond true and false: Expressive Authorization in Laravel
Most Laravel tutorials stop at Gate::define returning a boolean. Production applications need richer feedback — why was access denied, not just that it was. Laravel's authorization layer supports this, but the API is easy to miss.
Gate Responses
Illuminate\Auth\Access\Response lets a gate or policy method return a structured denial with a human-readable message and an optional HTTP status code.
use Illuminate\Auth\Access\Response;
Gate::define('publish-post', function (User $user, Post $post): Response {
if ($user->id !== $post->author_id) {
return Response::deny('You do not own this post.', 403);
}
if (! $user->hasVerifiedEmail()) {
return Response::deny('Verify your email before publishing.', 403);
}
return Response::allow();
});
When you call Gate::inspect('publish-post', $post) you get back the full Response object:
$response = Gate::inspect('publish-post', $post);
if ($response->denied()) {
return back()->withErrors($response->message());
}
This is far more useful than catching AuthorizationException and displaying a generic 403 page.
Policy Before-Hooks
Every policy can define a before method that runs ahead of every other policy method. Use it for super-admin bypass or global read-only mode — but be deliberate: returning null falls through to the real method, while returning true or false short-circuits everything.
class PostPolicy
{
public function before(User $user, string $ability): bool|null
{
// Super-admins bypass all post checks.
if ($user->hasRole('super-admin')) {
return true;
}
// Read-only mode: block all writes globally.
if (config('app.read_only_mode') && in_array($ability, ['create', 'update', 'delete'])) {
return false;
}
// Fall through to the individual policy method.
return null;
}
public function update(User $user, Post $post): Response
{
return $user->id === $post->author_id
? Response::allow()
: Response::deny('Only the author may edit this post.');
}
}
The null return is the critical detail. Omitting it (or returning false by default) silently blocks legitimate users.
Reusable Ownership Guards
Repeating $user->id === $model->user_id across dozens of policies is a maintenance hazard. Extract it into a typed, reusable guard:
namespace App\Authorization;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Auth\User;
use Illuminate\Auth\Access\Response;
final class OwnershipGuard
{
public function check(
User $user,
Model $model,
string $ownerKey = 'user_id',
string $denyMessage = 'You do not own this resource.'
): Response {
return (int) $model->{$ownerKey} === $user->id
? Response::allow()
: Response::deny($denyMessage, 403);
}
}
Inject it into your policies via the service container:
class CommentPolicy
{
public function __construct(private OwnershipGuard $ownership) {}
public function delete(User $user, Comment $comment): Response
{
return $this->ownership->check($user, $comment, denyMessage: 'Only the comment author may delete it.');
}
}
Because Laravel resolves policies through the container, constructor injection works out of the box — no manual wiring needed.
Authorizing in Form Requests
Keep controllers thin by moving authorization into FormRequest::authorize:
class UpdatePostRequest extends FormRequest
{
public function authorize(): bool
{
// Gate::inspect is available but authorize() must return bool.
return $this->user()->can('update', $this->route('post'));
}
}
For richer denial messages from FormRequest, override failedAuthorization:
protected function failedAuthorization(): void
{
$response = Gate::inspect('update', $this->route('post'));
throw new AuthorizationException($response->message(), $response->status() ?? 403);
}
Key Takeaways
- Use
Response::deny($message, $status)instead of barefalseto surface actionable denial reasons. before()returningnullis intentional — it signals fall-through, not denial.- Extract repeated ownership checks into an injectable
OwnershipGuardto keep policies DRY. Gate::inspect()gives you the fullResponseobject; prefer it overGate::allows()when you need the message.- Move
can()calls intoFormRequest::authorizeand overridefailedAuthorizationfor rich HTTP responses.