DEV Community

Cover image for ๐Ÿšจ 10 Authorization Anti-Patterns I Keep Finding in Laravel Apps (And the One-Line Fixes That Save Them)
Hossein Hezami
Hossein Hezami

Posted on

๐Ÿšจ 10 Authorization Anti-Patterns I Keep Finding in Laravel Apps (And the One-Line Fixes That Save Them)

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

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);
    }
    // ...
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

Or even cleaner โ€” middleware:

Route::resource('posts', PostController::class)
    ->middleware('pm:permission:posts.view');
Enter fullscreen mode Exit fullscreen mode

๐Ÿšซ 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
]);
Enter fullscreen mode Exit fullscreen mode

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.*']);
Enter fullscreen mode Exit fullscreen mode

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
...
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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'],
        ],
    ],
]);
Enter fullscreen mode Exit fullscreen mode

Now the controller, the policy, the middleware, and the blade directive all use the same rule:

$user->canPermission('posts.update', $post);
Enter fullscreen mode Exit fullscreen mode

๐Ÿšซ 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();
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

With cache tags (Redis/Memcached):

Cache::tags(["role:{$roleId}"])->flush();
Cache::tags(["user:{$userId}"])->flush();
Enter fullscreen mode Exit fullscreen mode

And a CLI command to warm the cache on deploy:

php artisan permission:cache:warm
Enter fullscreen mode Exit fullscreen mode

๐Ÿšซ 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?
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Set the active tenant via middleware:

Route::middleware(['pm.team:header,X-Team-Id'])->group(function () {
    // Every check inside is scoped
});
Enter fullscreen mode Exit fullscreen mode

๐Ÿšซ 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: false doesn'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']
Enter fullscreen mode Exit fullscreen mode

And from the terminal:

$ php artisan permission:why 42 refunds.view

  User:    42
  Ability: refunds.view
  โœ— DENIED
  Reason:  explicit_user_deny
  Source:  direct_permission
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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.*.edit vs users.{id}.edit becomes 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');  // โŒ
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

Each record captures:

actor_id, action, subject_type, subject_id, role_id,
permission_id, effect, ip_address, user_agent, metadata, created_at
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

And in code:

$snapshot = PermissionManager::snapshot($user);
// Roles, allow list, deny list, inherited roles, merged permissions โ€” all in one object
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)