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 - The Request: DELETE /posts/42 - Layer 1: The Route & Middleware - Layer 2: The Middleware Parser - Layer 3: Gate::before Intercepts - Layer 4: AuthorizationManager Receives the Call - Layer 5: Super-Admin Bypass - Layer 6: Explicit User Deny - Layer 7: Direct User Allow - Layer 8: Role Resolution (With Inheritance) - Layer 9: Wildcard Matching - Layer 10: ABAC Condition Evaluation - Layer 11: Team Context Filter - Layer 12: Cache Hit (or Miss) - Layer 13: Audit Event Fires - Layer 14: The AuthorizationResult Returns - What the Developer Sees - When the Check Fails: The Explain API - The Full Picture - Final Thoughts โ 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=... Ana's state in the database: - User #42, email a**@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']); 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); } 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, ] 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 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; } }); 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 } 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'); } } 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'); 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] ); } 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'); 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 // 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; } 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($
Comments
No comments yet. Start the discussion.