TL;DR: After reviewing hundreds of Laravel codebases, the same 10 authorization mistakes show up again and again. This article walks through each anti-pattern with real before/after code โ and shows how a single package, Laravel Permission Manager, fixes all ten out of the box.
๐ GitHub Repository ยท ๐ฆ Packagist
๐ Table of Contents
- The Pattern Behind the Patterns
- Anti-Pattern #1: The
if ($user->is_admin)Sprawl - Anti-Pattern #2: The God Role
- Anti-Pattern #3: Role Explosion
- Anti-Pattern #4: The Scattered Policy
- Anti-Pattern #5: The Cache Trap
- Anti-Pattern #6: The "Works on My Machine" Tenant
- Anti-Pattern #7: The Silent Deny
- Anti-Pattern #8: The Regex Wildcard Hell
- Anti-Pattern #9: The Missing Audit Trail
- Anti-Pattern #10: The 3 AM Debug Session
- One Package, Ten Fixes
- Comparison with Spatie
- Final Thoughts
๐ The Pattern Behind the Patterns
I've reviewed a lot of Laravel authorization code. Startup MVPs. Enterprise SaaS platforms. Government portals. Fintech apps. The tech stacks differ, the domains differ, the team sizes differ โ but the authorization mistakes are almost identical.
It's not because developers are careless. It's because authorization is one of those problems that looks trivial on day 1 and becomes a monster by month 12. You start with if ($user->is_admin). You end up with 47 roles, scattered policies, stale caches, and support tickets you can't explain.
This article collects the 10 anti-patterns I see most often, with real before/after code. If you recognize more than three of them in your own codebase โ this article is for you.
๐ซ Anti-Pattern #1: The if ($user->is_admin) Sprawl
The symptom
Your controllers look like a bowl of spaghetti:
public function index()
{
if (!auth()->user()->is_admin && !auth()->user()->is_manager) {
abort(403);
}
// ...
}
public function edit($id)
{
$post = Post::findOrFail($id);
if (!auth()->user()->is_admin && $post->user_id !== auth()->id()) {
abort(403);
}
// ...
}
public function delete($id)
{
if (!auth()->user()->is_admin) {
abort(403);
}
// ...
}
Same logic, written 14 different ways, in 23 different files.
Why it breaks
- Inconsistency: Every controller reinvents the check.
- Untestable: You have to test each controller separately.
- Brittle: When a new "senior editor" role is added, you hunt down 40 files.
The fix
Use a single, declarative authorization check:
public function index()
{
abort_unless(auth()->user()->hasPermissionTo('posts.view'), 403);
}
public function edit(Post $post)
{
abort_unless(auth()->user()->canPermission('posts.edit', $post), 403);
}
Or even cleaner โ middleware:
Route::resource('posts', PostController::class)
->middleware('pm:permission:posts.view');
๐ซ Anti-Pattern #2: The God Role
The symptom
You have a single admin role that can do everything โ from managing users to editing blog posts to exporting financial reports.
// Somewhere in a seeder
$admin->assignPermission([
'users.*', 'posts.*', 'billing.*', 'reports.*', 'system.*',
// ... 200 more lines
]);
Why it breaks
- Security risk: A compromised admin account has the keys to the kingdom.
- No granularity: You can't give someone "admin for billing" but not "admin for users."
- Audit nightmare: "Admin did something bad" tells you nothing.
The fix
Use role hierarchy instead of one mega-role:
$superAdmin = Role::create(['name' => 'Super Admin']);
$billingAdmin = Role::create(['name' => 'Billing Admin']);
$contentAdmin = Role::create(['name' => 'Content Admin']);
$superAdmin->inheritFrom('billingAdmin');
$superAdmin->inheritFrom('contentAdmin');
$billingAdmin->assignPermission(['billing.*', 'invoices.*']);
$contentAdmin->assignPermission(['posts.*', 'categories.*']);
Now super-admin inherits everything through composition, and you can promote someone to billing-admin without handing them the kingdom.
๐ซ Anti-Pattern #3: Role Explosion
The symptom
Your database has 47 roles, and they all look like this:
admin
admin-no-delete
admin-no-billing
admin-except-reports
senior-editor
senior-editor-no-publish
junior-editor
junior-editor-drafts-only
...
Each role exists because someone said "I need an admin who can't delete users" โ and the only tool in the toolbox was "create a new role."
Why it breaks
- Cognitive overload: No one can remember what each role does.
- Maintenance hell: Changing one permission requires updating 15 roles.
-
User confusion: "Why am I a
senior-editor-no-publish?"
The fix
Add explicit deny as a first-class concept:
// One role, one exception
$user->assignRole('admin');
$user->denyPermissionTo('users.delete');
// The resolution order handles it:
// 1. Explicit User DENY โ wins
// 2. Role ALLOW
One line replaces an entire "admin-no-delete" role.
๐ซ Anti-Pattern #4: The Scattered Policy
The symptom
Authorization logic is smeared across policies, controllers, middleware, and blade files:
// PostPolicy.php
public function update($user, $post) {
return $user->id === $post->user_id;
}
// PostController.php
public function update(Request $request, Post $post) {
if ($post->status === 'published' && !$user->is_admin) {
abort(403); // Wait, this isn't in the policy?
}
// ...
}
// post/edit.blade.php
@if ($post->user_id === auth()->id() || auth()->user()->is_admin)
<button>Edit</button>
@endif
Three different files, three different rules. They'll eventually drift.
Why it breaks
- Drift: The policy, controller, and UI drift out of sync.
- Hidden rules: Some rules only exist in one place.
- Debugging hell: "Why can I see the button but not submit the form?"
The fix
One source of truth โ ABAC conditions stored in the database:
PermissionCondition::create([
'permission_id' => Permission::findByRoute('posts.update')->id,
'conditions' => [
'any' => [
['field' => 'user.id', 'operator' => '=', 'value' => 'resource.user_id'],
['field' => 'user.role', 'operator' => '=', 'value' => 'admin'],
],
],
]);
Now the controller, the policy, the middleware, and the blade directive all use the same rule:
$user->canPermission('posts.update', $post);
๐ซ Anti-Pattern #5: The Cache Trap
The symptom
You cache permissions to speed things up:
$permissions = Cache::remember("user.{$user->id}.permissions", 3600, function () use ($user) {
return $user->roles->flatMap->permissions->pluck('route')->unique();
});
Then an admin revokes a role. The user still has the permission for the next hour. Support ticket incoming.
Why it breaks
- Stale permissions: Cache doesn't know about mutations.
- No cascade: Changing a role's permissions doesn't invalidate user caches.
- Silent failures: The system is "correct" โ just out of date.
The fix
Hierarchical cache invalidation that cascades through the dependency graph:
// When a role's permission changes:
$role->forgetCachedPermissions();
// Automatically:
// 1. Clears role's own cache
// 2. Clears ALL users with this role
// 3. Clears roles that INHERIT from this role
// 4. Clears users of those inherited roles
With cache tags (Redis/Memcached):
Cache::tags(["role:{$roleId}"])->flush();
Cache::tags(["user:{$userId}"])->flush();
And a CLI command to warm the cache on deploy:
php artisan permission:cache:warm
๐ซ Anti-Pattern #6: The "Works on My Machine" Tenant
The symptom
In development, you test as a user who belongs to one tenant. Everything works.
In production, a user belongs to three tenants with different roles in each โ and suddenly "admin" means different things in different contexts.
// Dev: simple
$user->assignRole('admin');
// Prod: chaos
// User is admin at Acme but viewer at Globex
// Which "admin" does `hasRole('admin')` check?
Why it breaks
- Ambiguous context: Global roles don't model multi-tenant reality.
- Privilege escalation: A viewer in one tenant may accidentally get admin access in another.
- Data leaks: Users see data across tenants.
The fix
Team-scoped roles from day one:
$user->assignRoleForTeam('admin', $acme);
$user->assignRoleForTeam('viewer', $globex);
$user->hasRoleForTeam('admin', $acme); // โ
true
$user->hasRoleForTeam('admin', $globex); // โ false
Set the active tenant via middleware:
Route::middleware(['pm.team:header,X-Team-Id'])->group(function () {
// Every check inside is scoped
});
๐ซ Anti-Pattern #7: The Silent Deny
The symptom
A user reports: "I can't see the Refunds page anymore."
You check their roles. They have finance-manager. The role has refunds.*. Everything should work. Yet hasPermissionTo('refunds.view') returns false.
You spend three hours tracing through code, only to find someone added a direct deny during a demo six weeks ago.
Why it breaks
-
Boolean blindness:
falsedoesn't tell you why. - Hidden state: Deny rules, expiry, conditions โ all invisible.
- Time sink: Every incident becomes an archaeology dig.
The fix
Every check returns a rich result object, not a boolean:
$result = $user->authorizePermission('refunds.view');
$result->isDenied(); // true
$result->getReason(); // 'explicit_user_deny'
$result->getSource(); // 'direct_permission'
$result->getMetadata(); // ['permission_id' => 42, 'effect' => 'deny']
And from the terminal:
$ php artisan permission:why 42 refunds.view
User: 42
Ability: refunds.view
โ DENIED
Reason: explicit_user_deny
Source: direct_permission
Mystery solved in 10 seconds.
๐ซ Anti-Pattern #8: The Regex Wildcard Hell
The symptom
Your permission matching looks like this:
public function hasPermissionTo($permission) {
foreach ($this->permissions as $p) {
$regex = str_replace(['.', '*'], ['\.', '.*'], $p);
if (preg_match('/^' . $regex . '$/', $permission)) {
return true;
}
}
return false;
}
It mostly works. Until someone adds admin.users.*.edit and it breaks. Or !users.delete (negation), which the regex can't express.
Why it breaks
- Leaky abstraction: Regex is an implementation detail, not a contract.
- No negation: "Everything except X" requires awkward workarounds.
-
No nesting:
users.*.editvsusers.{id}.editbecomes a mess.
The fix
A dedicated wildcard engine with a clean syntax:
$matcher = app(WildcardMatcher::class);
$matcher->matches('users.*', 'users.edit'); // โ
$matcher->matches('*.edit', 'users.edit'); // โ
$matcher->matches('!users.delete', 'users.view'); // โ
(negation!)
$matcher->matches('!users.delete', 'users.delete'); // โ
The engine is tested in isolation, cached, and reused across trait, middleware, and CLI.
๐ซ Anti-Pattern #9: The Missing Audit Trail
The symptom
A security audit asks: "Who granted billing.refund permissions in the last 90 days?"
You stare at the database. There's no record. The only clue is a row in role_permissions โ with no timestamp, no actor, no IP.
Why it breaks
- Compliance failure: SOC 2, ISO 27001, GDPR all require audit trails.
- Forensics impossible: Can't reconstruct what happened.
- Blame game: "Someone did it" isn't useful.
The fix
Automatic audit logging on every mutation:
// Every assignRole, givePermissionTo, etc. is automatically logged
PermissionAudit::byActor($adminId)->get();
PermissionAudit::action('granted')->where('effect', 'deny')->get();
Each record captures:
actor_id, action, subject_type, subject_id, role_id,
permission_id, effect, ip_address, user_agent, metadata, created_at
Compliance audit? Solved in one query.
๐ซ Anti-Pattern #10: The 3 AM Debug Session
The symptom
It's 3 AM. A production incident. A user can't do something they should be able to do.
Your debugging toolkit:
-
dd($user->roles)โ "Yep, has the role" -
dd($user->permissions())โ "Yep, has the permission" -
dd($user->hasPermissionTo('refunds.view'))โ false???
Three hours later, you discover it was a cache issue, or an expired temporary permission, or a tenant context that got lost in a redirect.
Why it breaks
- No observability: Authorization is a black box.
- No snapshots: Can't freeze state for analysis.
- No health checks: Problems fester until users report them.
The fix
A complete observability toolkit:
# Why was access denied?
php artisan permission:why 42 refunds.view
# What's this user's complete state?
php artisan permission:explain 42 refunds.view --json
# System-wide health check
php artisan permission:doctor
# Visualize the role hierarchy
php artisan permission:tree
And in code:
$snapshot = PermissionManager::snapshot($user);
// Roles, allow list, deny list, inherited roles, merged permissions โ all in one object
3 AM becomes 3 minutes.
๐ฆ One Package, Ten Fixes
Every anti-pattern above is solved by Laravel Permission Manager v2.0:
| Anti-Pattern | The Fix |
|---|---|
#1 is_admin sprawl |
Declarative hasPermissionTo() + middleware DSL |
| #2 God Role | Role hierarchy with inheritFrom()
|
| #3 Role explosion | Explicit denyPermissionTo() with deny-wins resolution |
| #4 Scattered Policy | ABAC conditions stored in DB, shared everywhere |
| #5 Cache trap | Hierarchical cache invalidation + tags + cache:warm
|
| #6 Tenant chaos | Team-scoped roles + pm.team middleware |
| #7 Silent deny |
AuthorizationResult with reason/source/metadata |
| #8 Regex hell | Dedicated WildcardMatcher with negation |
| #9 No audit | Automatic PermissionAudit on every mutation |
| #10 3 AM debugging |
permission:why + explain + doctor + snapshot
|
composer require hosseinhezami/laravel-permission-manager
Supports Laravel 10, 11, 12, and 13, works with PHP 8.2+, and ships with 141 passing tests.
๐ Comparison with Spatie
For context, here's how the two packages handle these anti-patterns:
| Feature | Laravel Permission Manager | Spatie Permission |
|---|---|---|
| RBAC | โ | โ |
| Direct Permissions | โ | โ |
| Explicit Allow/Deny | โ | โ |
| Role Hierarchy | โ Multi-level + cycle detection | โ |
| Temporary Permissions | โ Auto-expiry | โ |
| Teams / Multi-Tenancy | โ | โ |
| Multi-Guard (real) | โ guard_name column | โ |
| ABAC / Conditions | โ JSON engine (no eval) | โ |
| Audit Logging | โ Built-in | โ |
| Authorization Trail | โ Configurable | โ |
| Explain API | โ | โ |
| Permission Snapshot | โ | โ |
| CLI Doctor / Tree / Why | โ | โ |
| Permission Groups & Sets | โ | โ |
| Gate Integration | โ Native | โ |
| Middleware DSL | โ any/all/not | โ |
| Blade Directives | โ 12+ | โ |
| Testing Helpers | โ Built-in | โ |
| Tests | 141 | ~300 |
| Laravel 13 Support | โ | โ ๏ธ |
| Backward Compatible | โ 100% | โ |
Bottom line: Spatie remains an excellent, battle-tested choice for standard RBAC. Laravel Permission Manager adds the enterprise layers โ hierarchy, deny rules, ABAC, tenancy depth, auditing, and debugging โ that Spatie doesn't cover.
๐ญ Final Thoughts
Authorization anti-patterns aren't mistakes. They're the natural consequence of starting simple and scaling without a plan.
Every Laravel app begins with if ($user->is_admin). The question isn't whether you'll hit these walls โ it's when, and whether you'll have the tools to climb them.
The good news? You don't have to solve them one by one. A single cohesive package can handle all ten, and give you a foundation that scales from MVP to enterprise without rewrites.
Count how many anti-patterns you recognized in your own codebase:
- 0โ2: You're in good shape. Keep watching.
- 3โ5: Time to refactor before it hurts.
- 6โ10: You know what to do. ๐
composer require hosseinhezami/laravel-permission-manager
If this article helped you spot a few anti-patterns, a โญ on GitHub keeps the work going.
๐ GitHub Repository ยท ๐ฆ Packagist
Tags: #laravel #php #authorization #rbac #abac #security #webdev #antipatterns #refactoring #opensource #bestpractices #backend
Top comments (0)