Beyond true and false: Response-Based Authorization
Most Laravel codebases treat gates and policies as boolean switches. That works until a product manager asks: "Can we show users why they were denied?" Laravel has had Illuminate\Auth\Access\Response since v7, yet it remains underused.
use Illuminate\Auth\Access\Response;
public function update(User $user, Post $post): Response
{
if ($user->id === $post->user_id) {
return Response::allow();
}
if ($post->is_locked) {
return Response::deny('This post is locked for editing.', 423);
}
return Response::deny('You do not own this post.', 403);
}
The second argument to deny() becomes the HTTP status code when the policy is enforced via $this->authorize() in a controller. The message surfaces in the message key of the JSON error response automatically — no custom exception handler needed.
Retrieving the Response Without Throwing
When you need the denial reason in application logic (not HTTP), use Gate::inspect():
$response = Gate::inspect('update', $post);
if ($response->denied()) {
Log::warning('Authorization denied', [
'reason' => $response->message(),
'code' => $response->code(),
]);
return back()->withErrors($response->message());
}
This keeps your controllers thin and your audit trail rich.
Policy Composition with before Hooks
Avoid duplicating superadmin checks across every policy method. The before hook short-circuits the entire policy:
public function before(User $user, string $ability): ?bool
{
if ($user->hasRole('super_admin')) {
return true; // grants everything; return null to fall through
}
return null;
}
Return null (not false) to let the specific method run. Returning false from before denies unconditionally — a subtle but critical distinction.
Composing Policies via Dependency Injection
Policies are resolved through the service container, so you can inject domain services:
class PostPolicy
{
public function __construct(
private readonly SubscriptionService $subscriptions
) {}
public function create(User $user): Response
{
return $this->subscriptions->isActive($user)
? Response::allow()
: Response::deny('An active subscription is required.', 402);
}
}
Register the policy normally in AuthServiceProvider. Laravel resolves constructor dependencies automatically.
Gate Definitions for Non-Model Abilities
Not every authorization check maps to an Eloquent model. Use Gate::define for cross-cutting abilities:
// AppServiceProvider::boot()
Gate::define('access-beta-features', function (User $user): Response {
return $user->beta_tester
? Response::allow()
: Response::deny('Beta access is invite-only.', 403);
});
Call it anywhere: Gate::authorize('access-beta-features') or @can('access-beta-features') in Blade.
Testing Authorization with Pest
Test policies in isolation — no HTTP overhead required:
use App\Models\{Post, User};
use App\Policies\PostPolicy;
use Illuminate\Auth\Access\Response;
it('denies update when post is locked', function () {
$user = User::factory()->create();
$post = Post::factory()->for($user)->locked()->create();
$response = (new PostPolicy)->update($user, $post);
expect($response)->toBeInstanceOf(Response::class)
->and($response->denied())->toBeTrue()
->and($response->code())->toBe(423);
});
it('allows super_admin via before hook', function () {
$admin = User::factory()->superAdmin()->create();
$post = Post::factory()->create();
expect((new PostPolicy)->before($admin, 'update'))->toBeTrue();
});
Testing the policy class directly is faster than firing HTTP requests and keeps the feedback loop tight.
Key Takeaways
- Use
Response::deny($message, $code)to return machine-readable denial reasons, not justfalse. Gate::inspect()retrieves the response object without throwing, ideal for logging and UI feedback.- The
beforehook is the correct place for superadmin bypass — returnnullto fall through, notfalse. - Policies are container-resolved; inject domain services freely.
- Test policy classes directly with Pest for fast, isolated authorization coverage.