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
editorrole 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.deletepermission."
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
ana@acme.com - Has role
editorin team #7 (Acme) - Has an explicit deny on
posts.delete(set by her manager last month) - The post #42 is in
draftstatus 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($regex, $ability);
}
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" }
]
}
}
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);
}
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);
}
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);
}
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:
- Clear
pm:role:3:permissions - Find all users with role 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));
}
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"
}
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)
],
π· 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']
);
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());
}
Ana's browser receives:
HTTP/1.1 403 Forbidden
{
"message": "explicit_user_deny"
}
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
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β 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
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
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
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)