Advanced Authorization in Laravel: Gates, Policies, and Response-Based Access Control
#laravel #authorization #security #pest #policies

Advanced Authorization in Laravel: Gates, Policies, and Response-Based Access Control

3 min read Mohamed Said Mohamed Said

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 just false.
  • Gate::inspect() retrieves the response object without throwing, ideal for logging and UI feedback.
  • The before hook is the correct place for superadmin bypass — return null to fall through, not false.
  • Policies are container-resolved; inject domain services freely.
  • Test policy classes directly with Pest for fast, isolated authorization coverage.

Found this useful?

Frequently Asked Questions

3 questions
Q01 What is the difference between returning `false` and `null` from a policy's `before` method?
Returning `false` unconditionally denies the ability for that user, bypassing the specific policy method. Returning `null` signals that `before` has no opinion and Laravel should continue to the named policy method.
Q02 How does the HTTP status code in `Response::deny()` get applied to the response?
When you call `$this->authorize()` in a controller and the policy returns a denial response, Laravel throws an `AuthorizationException` that carries the custom code. The exception handler converts it to an HTTP response using that code automatically.
Q03 Can I use response-based authorization with Filament?
Yes. Filament calls standard Laravel policies for record actions. If a policy returns `Response::deny($message)`, Filament surfaces the message in its notification system when the action is blocked.

Continue reading

More Articles

View all