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

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 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::before is the right place for super-admin bypass — keep it out of individual policies.
  • Gate::after is audit-only; it cannot change the authorization outcome.
  • Traits are a clean way to share ownership or role checks across multiple policies.
  • Nullable ?User type hints are required to handle guest authorization explicitly in policies.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Can I return a Response object from a Gate closure, or only from Policies?
Yes. Gate closures can return `Response::allow()` or `Response::deny()` just like policy methods. The Gate resolves the boolean result from the Response automatically when you call `Gate::allows()` or `Gate::check()`.
Q02 Does the machine-readable code in Response::deny() get exposed automatically in JSON responses?
Not automatically. You must catch `AuthorizationException` and call `$e->response()?->code()` yourself, either in the controller or in a global exception handler. Laravel does not include it in the default 403 response.
Q03 What is the difference between Gate::before and a super-admin check inside each policy?
`Gate::before` is a single, centralized hook that fires before every authorization check application-wide. Putting the check inside each policy duplicates logic and risks missing it when new policies are added.

Continue reading

More Articles

View all