DEV Community

Cover image for The Life of a Permission Check: Following One Request Through 14 Layers of Authorization
Hossein Hezami
Hossein Hezami

Posted on

The Life of a Permission Check: Following One Request Through 14 Layers of Authorization

TL;DR: What actually happens when a user clicks "Delete" in your Laravel app? Most developers think it's a single boolean check. In reality, a modern authorization engine evaluates the request across 14+ layers β€” middleware, Gate hooks, super-admin bypass, explicit deny, role inheritance, wildcard matching, ABAC conditions, cache lookups, audit logging, and more. This article traces one real request through every layer, with code, diagrams, and the exact moment each decision fires.

πŸ”— GitHub Repository Β· πŸ“¦ Packagist


πŸ“‹ Table of Contents


❓ The Question Nobody Asks

Here's a question I ask Laravel developers in interviews:

"A user with the editor role clicks Delete on a post. What happens between the click and the 403 or 200 response?"

99% of them answer something like:

"The middleware checks if they have the posts.delete permission."

That answer was correct in 2018. Today, it's like saying a car "burns gas" β€” technically true, but missing the 400 other things happening at the same time.

A modern authorization check is a multi-layered evaluation with caching, inheritance, conditions, tenant scoping, audit trails, and rich result objects. Most developers only see the first and last layers. Everything in between is a black box.

This article opens that black box. We'll follow one real request β€” DELETE /posts/42 β€” through every layer of Laravel Permission Manager, from the moment the HTTP request hits Laravel to the moment the response comes back.

By the end, you'll understand exactly what a modern authorization check does, and why building one from scratch is much harder than it looks.


🎬 The Request: DELETE /posts/42

Our protagonist is Ana, an editor at a multi-tenant SaaS app. She's logged in as user #42 and clicks Delete on a post she authored.

The request:

DELETE /posts/42 HTTP/1.1
Host: app.acme.com
X-Team-Id: 7
Cookie: laravel_session=...
Enter fullscreen mode Exit fullscreen mode

Ana's state in the database:

  • User #42, email ana@acme.com
  • Has role editor in team #7 (Acme)
  • Has an explicit deny on posts.delete (set by her manager last month)
  • The post #42 is in draft status and she owns it

What happens next takes about 4 milliseconds, but crosses 14 distinct layers of logic. Let's walk through them.


πŸ”· Layer 1: The Route & Middleware

The request hits Laravel's router. The route looks like this:

Route::delete('/posts/{post}', [PostController::class, 'destroy'])
    ->middleware(['auth', 'pm:permission:posts.delete']);
Enter fullscreen mode Exit fullscreen mode

The auth middleware runs first β€” Ana is authenticated, so we move on.

Then pm:permission:posts.delete runs. The pm middleware is the gateway to the entire authorization system.

// src/Middleware/CheckPermission.php
public function handle($request, Closure $next, ...$args)
{
    $parsed = $this->parser->parse(implode(':', $args));

    $result = app(AuthorizationManager::class)->check(
        $request->user(),
        $parsed['ability'],
        $request->route()?->parameter('post'), // contextual resource
    );

    if ($result->isDenied()) {
        abort(403, $result->getReason());
    }

    return $next($request);
}
Enter fullscreen mode Exit fullscreen mode

Before the authorization engine even runs, the middleware has already done something important: it extracted the resource (the Post model) from the route and will pass it to the engine. This is how contextual checks become possible later.


πŸ”· Layer 2: The Middleware Parser

The string permission:posts.delete looks simple, but it's actually a mini-language. The MiddlewareParser breaks it down:

// src/Support/MiddlewareParser.php
$parsed = $parser->parse('permission:posts.delete');

// Returns:
[
    'type' => 'permission',
    'mode' => 'single',
    'values' => ['posts.delete'],
    'negate' => false,
]
Enter fullscreen mode Exit fullscreen mode

The same parser also handles:

'permission:any:users.view|users.edit'   // OR logic
'permission:all:users.view|users.edit'   // AND logic
'permission:not:admin.panel'             // Negation
'role:any:admin|editor'                  // Role-based
Enter fullscreen mode Exit fullscreen mode

Ana's request resolves to a simple single-permission check for posts.delete. The parsed structure is handed to the AuthorizationManager.


πŸ”· Layer 3: Gate::before Intercepts

If you've ever used $user->can('posts.delete') anywhere in your app, it just works with this package. The reason is a Gate::before hook registered in the service provider:

// src/PermissionManagerServiceProvider.php
Gate::before(function ($user, $ability) {
    if (method_exists($user, 'hasPermissionTo')) {
        return $user->hasPermissionTo($ability) ?: null;
    }
});
Enter fullscreen mode Exit fullscreen mode

In our case, the request came through middleware, so this hook isn't directly invoked. But the hook exists so that anywhere else in the codebase β€” blade templates, controllers, policies β€” the native @can directive and $user->can() method delegate into the same authorization engine. One source of truth, many entry points.


πŸ”· Layer 4: AuthorizationManager Receives the Call

The core of the package. One class, one entry point:

// src/Authorization/AuthorizationManager.php
public function check($user, string $ability, $resource = null): AuthorizationResult
{
    // 1. Super-admin bypass
    // 2. Explicit user deny
    // 3. Explicit user allow
    // 4. Direct conditional permissions
    // 5. Role deny
    // 6. Role allow
    // 7. Inherited roles
    // 8. Wildcard resolution
    // 9. ABAC conditions
    // 10. Team context
    // 11. Expiration check
    // 12. Default deny
}
Enter fullscreen mode Exit fullscreen mode

The manager takes the user, the ability (posts.delete), and the resource (the Post model) β€” and begins the evaluation. It will not return true or false. It will return an AuthorizationResult object that carries the reason for the decision.

Let's walk through each step it evaluates.


πŸ”· Layer 5: Super-Admin Bypass

First check: is Ana a super-admin?

if (config('permission-manager.super_admin.enabled')) {
    $superRole = config('permission-manager.super_admin.role_slug');
    if ($user->hasRole($superRole)) {
        return AuthorizationResult::allowed('super_admin_bypass');
    }
}
Enter fullscreen mode Exit fullscreen mode

Ana has the editor role, not super-admin. We move on.

πŸ’‘ Note how this check runs in O(1) β€” it doesn't load all of Ana's permissions. The manager is designed to short-circuit as early as possible.


πŸ”· Layer 6: Explicit User Deny

This is where Ana's story takes a turn. Six weeks ago, her manager added an explicit deny on posts.delete after she accidentally deleted a published article:

// What her manager ran six weeks ago
$ana->denyPermissionTo('posts.delete');
Enter fullscreen mode Exit fullscreen mode

This created a row in the user_permissions pivot with effect = 'deny'.

The manager queries Ana's direct permissions:

$denyResult = $this->findUserDecision($user, $ability, 'deny');
if ($denyResult && !$this->isExpired($denyResult)) {
    return AuthorizationResult::denied(
        reason: 'explicit_user_deny',
        source: 'direct_permission',
        metadata: ['permission_id' => $denyResult->id]
    );
}
Enter fullscreen mode Exit fullscreen mode

Stop. The evaluation ends here. The result is already decided β€” DENIED, with the reason explicit_user_deny.

This is the power of explicit deny. It doesn't matter what role Ana has, what wildcards match, what ABAC conditions say. Deny wins. One flag in the database overrides everything.

βš–οΈ The resolution order is deliberate: explicit user deny is checked before role allow. This is what makes the "everything except" pattern possible without creating a new role for every exception.


πŸ”· Layer 7: Direct User Allow

Had there been no deny, the next check would be for explicit user allow β€” direct permissions granted to Ana without going through a role. For example, if someone had run:

$ana->givePermissionTo('reports.export');
Enter fullscreen mode Exit fullscreen mode

Direct allows are checked before role allows. This gives you a way to make one-off exceptions without polluting your role system.

In Ana's case, she has no direct allow for posts.delete, so this step would have been skipped.


πŸ”· Layer 8: Role Resolution (With Inheritance)

Now the manager looks at Ana's roles. She has one: editor.

But editor inherits from viewer (via the role_inherits table), and the engine must walk the whole chain:

editor
  └── inherits from: viewer
Enter fullscreen mode Exit fullscreen mode
// src/Authorization/RoleResolver.php
public function resolve($user, string $ability): ?AuthorizationResult
{
    $roles = $user->roles()->with('inherits.permissions')->get();

    foreach ($roles as $role) {
        // Direct role permissions
        foreach ($role->permissions as $perm) {
            if ($this->matches($perm->route, $ability)) {
                return AuthorizationResult::allowed(
                    reason: 'role_allow',
                    source: 'role:' . $role->slug
                );
            }
        }

        // Inherited permissions
        foreach ($role->inherits as $parent) {
            foreach ($parent->permissions as $perm) {
                if ($this->matches($perm->route, $ability)) {
                    return AuthorizationResult::allowed(
                        reason: 'inherited_role_allow',
                        source: 'role:' . $parent->slug . ' via ' . $role->slug
                    );
                }
            }
        }
    }

    return null;
}
Enter fullscreen mode Exit fullscreen mode

If Ana had a parent role granting posts.delete, the result would carry that lineage in its source field β€” invaluable for debugging.

The engine also detects cycles (role A inherits B which inherits A) and throws CyclicRoleInheritanceException instead of hanging your app.


πŸ”· Layer 9: Wildcard Matching

Inside each role permission check, the string comparison isn't simple ===. It goes through the WildcardMatcher:

// src/Authorization/WildcardMatcher.php
public function matches(string $pattern, string $ability): bool
{
    if ($pattern === $ability) return true;

    // Handle negation: "!posts.delete"
    if (str_starts_with($pattern, '!')) {
        return !$this->matches(substr($pattern, 1), $ability);
    }

    // Convert wildcard to regex: "posts.*" β†’ "^posts\..*$"
    $regex = '/^' . str_replace(['*', '?'], ['.*', '.'], preg_quote($pattern, '/')) . '$/';
    return (bool) preg_match($regex, $ability);
}
Enter fullscreen mode Exit fullscreen mode

So if Ana's editor role had posts.*, it would match posts.delete, posts.edit, posts.publish β€” and the negation form !posts.delete would explicitly exclude posts.delete.


πŸ”· Layer 10: ABAC Condition Evaluation

Had a permission matched at the role level, the engine would check whether that permission has conditions attached:

// In the database: permission_conditions table
{
    "permission_id": 15,       // posts.update
    "conditions": {
        "all": [
            { "field": "user.id", "operator": "=", "value": "resource.user_id" },
            { "field": "resource.status", "operator": "=", "value": "draft" }
        ]
    }
}
Enter fullscreen mode Exit fullscreen mode

The ConditionEvaluator walks the JSON tree and evaluates it against the actual user and resource:

// src/Authorization/ConditionEvaluator.php
public function evaluate(array $condition, array $context): bool
{
    if (isset($condition['all'])) {
        foreach ($condition['all'] as $sub) {
            if (!$this->evaluate($sub, $context)) return false;
        }
        return true;
    }

    if (isset($condition['any'])) {
        foreach ($condition['any'] as $sub) {
            if ($this->evaluate($sub, $context)) return true;
        }
        return false;
    }

    // Leaf: field operator value
    $left = $this->resolve($condition['field'], $context);
    $right = $this->resolve($condition['value'], $context);
    return $this->compare($left, $condition['operator'], $right);
}
Enter fullscreen mode Exit fullscreen mode

Important: this is a whitelist-based evaluator. There's no eval(), no code injection, no PHP parsing. The only operators are the ones you declare. Safe by design.

For Ana's delete request, there are no ABAC conditions, so this layer is skipped.


πŸ”· Layer 11: Team Context Filter

Ana's request includes X-Team-Id: 7. Earlier in the request lifecycle, the pm.team middleware set the team context:

// src/Middleware/SetTeamContext.php
public function handle($request, Closure $next)
{
    $teamId = $request->header('X-Team-Id');
    if ($teamId) {
        app(TeamContext::class)->setTeamId($teamId);
    }
    return $next($request);
}
Enter fullscreen mode Exit fullscreen mode

Every role query inside the AuthorizationManager respects this context. Ana might be an editor in team 7 (Acme) but only a viewer in team 8 (Globex). The same user, different permissions, based on which tenant they're operating in.

For our request, the team context filters the role lookup to team 7 only.


πŸ”· Layer 12: Cache Hit (or Miss)

None of this hits the database on every request. The CacheService wraps each layer:

// src/Services/CacheService.php
public function rememberTagged(array $tags, string $key, callable $callback)
{
    if ($this->supportsTags()) {
        return Cache::tags($tags)->remember($key, $ttl, $callback);
    }
    return Cache::remember($key, $ttl, $callback);
}
Enter fullscreen mode Exit fullscreen mode

For Ana:

  • pm:user:42:roles β†’ cached
  • pm:user:42:permissions β†’ cached
  • pm:role:3:permissions β†’ cached (editor role)
  • pm:role:3:inherits β†’ cached

And here's the clever part: when someone changes the editor role's permissions, the CacheService cascades invalidation:

  1. Clear pm:role:3:permissions
  2. Find all users with role 3
  3. Clear each user's pm:user:X:permissions

No stale permissions. No 1-hour delays. Changes take effect immediately.


πŸ”· Layer 13: Audit Event Fires

Before the result returns, the manager dispatches an event:

// src/Authorization/AuthorizationManager.php
if ($result->isDenied()) {
    event(new AuthorizationDenied($user, $ability, $resource, $result));
}
Enter fullscreen mode Exit fullscreen mode

If audit logging is enabled, the AuthorizationLogger listener writes a row to authorization_logs:

{
    "user_id": 42,
    "ability": "posts.delete",
    "resource_type": "App\\Models\\Post",
    "resource_id": 42,
    "allowed": false,
    "reason": "explicit_user_deny",
    "source": "direct_permission",
    "ip_address": "192.168.1.100",
    "created_at": "2026-09-02 14:32:01"
}
Enter fullscreen mode Exit fullscreen mode

This is separate from the mutation audit log (which records who changed what). This log records who tried what, and was denied. It's gold for security audits, brute-force detection, and compliance reports.

You can tune it in config:

'authorization_logging' => [
    'enabled' => true,
    'denied_only' => true,     // Only log failures
    'sample_rate' => 1.0,      // Log 100% (or 0.1 for high-traffic apps)
],
Enter fullscreen mode Exit fullscreen mode

πŸ”· Layer 14: The AuthorizationResult Returns

The manager returns not a boolean, but a rich object:

$result = AuthorizationResult::denied(
    reason: 'explicit_user_deny',
    source: 'direct_permission',
    metadata: ['permission_id' => 87, 'matched_pattern' => 'posts.delete']
);
Enter fullscreen mode Exit fullscreen mode

The result exposes:

  • isAllowed() / isDenied()
  • getReason() β€” machine-readable (explicit_user_deny, role_allow, condition_failed, default_deny)
  • getSource() β€” where the decision came from (direct_permission, role:editor, condition:owner-only)
  • getMetadata() β€” extra context for debugging
  • toArray() β€” for API responses

This is what separates a boolean check from an observable authorization system.


πŸ‘€ What the Developer Sees

Back in the middleware:

if ($result->isDenied()) {
    abort(403, $result->getReason());
}
Enter fullscreen mode Exit fullscreen mode

Ana's browser receives:

HTTP/1.1 403 Forbidden
{
    "message": "explicit_user_deny"
}
Enter fullscreen mode Exit fullscreen mode

She sees a friendly "You don't have permission to delete posts" message (rendered by your frontend). But the backend has preserved the exact reason, which she (or her manager, or an admin) can investigate.


πŸ” When the Check Fails: The Explain API

Six weeks later, Ana asks her manager: "Why can't I delete posts anymore?"

The manager runs one command:

php artisan permission:why 42 posts.delete
Enter fullscreen mode Exit fullscreen mode
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  Permission Decision Explanation                β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

  User:    42 (Ana)
  Ability: posts.delete

  βœ— DENIED

  Reason:  explicit_user_deny
  Source:  direct_permission
  Details:
    - matched_pattern: posts.delete
    - permission_id: 87
    - granted_by: Manager #12
    - granted_at: 2026-07-18 09:14:22
    - via_ip: 10.0.0.5

  Resolution chain:
    βœ“ Role 'editor' would allow via wildcard 'posts.*'
    βœ— User has explicit DENY on posts.delete (set 46 days ago)
    β†’ Final decision: DENY
Enter fullscreen mode Exit fullscreen mode

Mystery solved in 10 seconds. No grepping logs. No dd($user->permissions). No Slack archaeology. The system tells you exactly what happened, when, and by whom.


πŸ—ΊοΈ The Full Picture

Here's every layer in one diagram:

HTTP Request: DELETE /posts/42
        β”‚
        β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ 1. Route & Middleware       β”‚
β”‚    pm:permission:posts.deleteβ”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
              β”‚
              β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ 2. Middleware Parser        β”‚
β”‚    {type: permission,       β”‚
β”‚     ability: posts.delete}  β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
              β”‚
              β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ 3. Gate::before (skipped)   β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
              β”‚
              β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ 4. AuthorizationManager     β”‚
β”‚    check($user, ability,    β”‚
β”‚          $post)             β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
              β”‚
              β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ 5. Super-admin bypass       β”‚  β†’ not super-admin
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
              β”‚
              β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ 6. Explicit user DENY       β”‚  β†’ βœ… FOUND
β”‚    posts.delete β†’ deny      β”‚  β†’ STOP HERE
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
              β”‚
              β–Ό
   (Steps 7-11 skipped)
              β”‚
              β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ 12. Cache lookup            β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
              β”‚
              β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ 13. Audit event             β”‚
β”‚    AuthorizationDenied      β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
              β”‚
              β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ 14. AuthorizationResult     β”‚
β”‚    { allowed: false,        β”‚
β”‚      reason: 'explicit_     β”‚
β”‚      user_deny' }           β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
              β”‚
              β–Ό
      HTTP 403 Forbidden
Enter fullscreen mode Exit fullscreen mode

Total execution time: ~4ms.
Layers evaluated before short-circuit: 2.
Database queries: 1 (cached).
Audit trail: 1 row.


πŸ’­ Final Thoughts

A permission check is not a boolean. It's a decision record β€” one that passes through middleware, parsing, Gate integration, bypass checks, deny rules, inheritance, wildcards, ABAC conditions, team context, cache, audit events, and a rich result object before the response goes out.

Most Laravel apps only implement layers 1 and 4. The other 12 layers are what separate a fragile authorization system from one that can scale to enterprise needs β€” and, more importantly, one you can debug when it breaks at 3 AM.

If you've ever wondered what "real" authorization looks like under the hood, now you know. And if you'd rather not build all 14 layers yourself, it's one composer require away:

composer require hosseinhezami/laravel-permission-manager
Enter fullscreen mode Exit fullscreen mode

Next time someone asks you "what happens when a user clicks Delete?" β€” you can tell them the whole story.


πŸ”— GitHub Repository Β· πŸ“¦ Packagist


Tags: #laravel #php #authorization #security #webdev #backend #architecture #opensource #rbac #abac #devtools #tutorial

Top comments (0)