DEV Community

Cover image for 5 Laravel Authorization Problems You're Probably Facing (And How to Solve Them in 2026)
Hossein Hezami
Hossein Hezami

Posted on

5 Laravel Authorization Problems You're Probably Facing (And How to Solve Them in 2026)

TL;DR: Most Laravel apps hit the same 5 authorization walls as they grow — role explosion, exception handling, multi-tenancy, contextual permissions, and debugging nightmares. This deep dive shows how to solve each one with modern patterns, and introduces a package that combines all solutions: Laravel Permission Manager.

🔗 GitHub · 📦 Packagist


📋 Table of Contents


🎯 Introduction: The Authorization Ceiling

Every Laravel project starts with the same authorization story:

// Day 1: Simple and beautiful
if ($user->is_admin) {
    // show admin stuff
}
Enter fullscreen mode Exit fullscreen mode

By month three, it looks like this:

// Month 3: Starting to hurt
if ($user->hasRole('admin') || 
    ($user->hasRole('editor') && $post->status === 'draft') ||
    ($user->hasRole('manager') && $post->department_id === $user->department_id)) {
    // ...
}
Enter fullscreen mode Exit fullscreen mode

By year one, you've got authorization logic scattered across controllers, policies, middleware, and blade templates — with no clear source of truth.

This is what I call "The Authorization Ceiling": the point where basic RBAC stops working and you need something more sophisticated.

In this article, we'll explore the 5 most common authorization problems Laravel developers hit, why traditional solutions fail, and how modern patterns (and modern packages) solve them cleanly.


🔴 Problem #1: The Role Explosion Trap

The Symptom

Your application has roles: admin, editor, viewer. Life is good.

Then the product team asks:

  • "Can we have an admin who can't delete users?"
  • "Can we have an editor who can publish but not delete?"
  • "Can we have a viewer who can export reports?"

Before you know it, you have 47 roles in your database. Every new exception creates a new role. HR calls it "role explosion." You call it "nightmare fuel."

Why Traditional RBAC Fails

Pure RBAC assumes a clean mapping: User → Role → Permissions.

Real-world requirements look like this:
User → Role(s) → Permissions → EXCEPTIONS

The "exceptions" part is what breaks pure RBAC. You need a way to grant or deny permissions directly to users, independent of their roles.

The Solution: Direct Permissions + Role Composition

Modern authorization systems support two parallel permission channels:

// Channel 1: Role-based (the norm)
$user->assignRole('editor');
// Gets: posts.view, posts.create, posts.edit

// Channel 2: Direct permissions (the exception)
$user->givePermissionTo('reports.export');
$user->denyPermissionTo('posts.delete');

// Final result:
// - posts.view     ✅ (from role)
// - posts.create   ✅ (from role)
// - posts.edit     ✅ (from role)
// - posts.delete   ❌ (explicit deny wins!)
// - reports.export ✅ (direct grant)
Enter fullscreen mode Exit fullscreen mode

The Resolution Order

The magic is in the resolution order. A well-designed system checks in this priority:

1. Super Admin bypass
2. Explicit User DENY        ← highest priority
3. Explicit Role DENY
4. Explicit User ALLOW
5. Role ALLOW
6. Inherited permissions
7. Default: DENY
Enter fullscreen mode Exit fullscreen mode

Deny always wins over allow. This single rule eliminates 80% of role explosion.


🔴 Problem #2: The "Except This One" Problem

The Symptom

A manager tells you: "Give the marketing team access to everything in the CMS — except the ability to delete blog posts."

In basic RBAC, you have three bad options:

  1. Create a new role (marketing-no-delete) — now you have 48 roles
  2. Don't grant posts.delete — but then you can't use the convenient posts.* wildcard
  3. Write custom middleware — now you have logic scattered everywhere

Why Wildcards Alone Don't Help

Wildcards are great for granting permissions:

$role->assignPermission('posts.*');  // Easy!
Enter fullscreen mode Exit fullscreen mode

But they're useless for exceptions. You can't express "everything except X" with a simple wildcard.

The Solution: Explicit Deny with Wildcards

Modern systems combine wildcards with explicit deny:

// Grant everything in posts.*
$marketing->assignPermission('posts.*');

// But explicitly deny delete
$marketing->denyPermission('posts.delete');

// Or use negation wildcards (advanced)
$marketing->assignPermission('!posts.delete');
Enter fullscreen mode Exit fullscreen mode

The Pattern in Action

// Senior Editor role
$seniorEditor = Role::create(['name' => 'Senior Editor']);
$seniorEditor->assignPermission([
    'posts.*',          // All post operations
    'categories.*',     // All category operations
    '!posts.delete',    // EXCEPT deleting posts
    '!posts.force-delete', // AND force-deleting
]);

// Now any user with this role:
$user->assignRole('senior-editor');

$user->hasPermissionTo('posts.view');         // ✅ true
$user->hasPermissionTo('posts.create');       // ✅ true
$user->hasPermissionTo('posts.edit');         // ✅ true
$user->hasPermissionTo('posts.publish');      // ✅ true
$user->hasPermissionTo('posts.delete');       // ❌ false (denied)
$user->hasPermissionTo('posts.force-delete'); // ❌ false (denied)
Enter fullscreen mode Exit fullscreen mode

One role. Multiple exceptions. Zero role explosion.


🔴 Problem #3: The Multi-Tenant Nightmare

The Symptom

You're building a SaaS. Company A has their own users with their own roles. Company B has the same. But they're in the same database.

Suddenly you realize:

  • An "admin" in Company A should NOT be admin in Company B
  • A user might be admin in Company A but only a viewer in Company B
  • Permissions need to be scoped to a tenant

Why Global Roles Fail

Traditional RBAC treats roles as global:

$user->assignRole('admin');
// Admin everywhere. In every tenant. Forever.
Enter fullscreen mode Exit fullscreen mode

For SaaS, you need:

$user->assignRoleForTeam('admin', $companyA);
$user->assignRoleForTeam('viewer', $companyB);
Enter fullscreen mode Exit fullscreen mode

The Solution: Team-Scoped Roles

Modern multi-tenant authorization introduces a team context:

// Setup
$companyA = Team::createTeam(['name' => 'Acme Corp']);
$companyB = Team::createTeam(['name' => 'Globex Inc']);

// User belongs to both companies
$user->joinTeam($companyA);
$user->joinTeam($companyB);

// Different roles per company
$user->assignRoleForTeam('admin', $companyA);
$user->assignRoleForTeam('editor', $companyB);

// Check permissions in context
$user->hasRoleForTeam('admin', $companyA);   // ✅ true
$user->hasRoleForTeam('admin', $companyB);   // ❌ false
$user->hasRoleForTeam('editor', $companyB);  // ✅ true
Enter fullscreen mode Exit fullscreen mode

Setting the Active Tenant

In controllers, you need to know which tenant is active:

// Option 1: Middleware (recommended)
Route::middleware(['pm.team:header,X-Team-Id'])->group(function () {
    // All permission checks here are scoped to the team from the header
});

// Option 2: Programmatic
PermissionManager::setTeam($currentTeam);
$user->hasPermissionTo('projects.delete'); // Scoped to $currentTeam
Enter fullscreen mode Exit fullscreen mode

Database Design

The schema adds a team_id pivot column:

user_roles
├── user_id
├── role_id
├── team_id   ← THE key addition
└── expires_at
Enter fullscreen mode Exit fullscreen mode

This lets one user have different roles in different tenants — without duplicating users.


🔴 Problem #4: The "Can They Edit THIS Post?" Problem

The Symptom

The requirement sounds simple: "Users can edit posts they own."

But it's deceptively complex:

  • "Own" is contextual — it depends on the specific post
  • A user might own Post A but not Post B
  • Admins can edit any post
  • Editors can edit their department's posts

Why Pure RBAC Can't Handle This

RBAC is binary: "Can the user do X? Yes or no."

But contextual authorization is conditional: "Can the user do X to Y?"

You need ABAC — Attribute-Based Access Control.

The Solution: Condition Engine

Modern systems define permissions with JSON conditions:

// Define the permission with a condition
PermissionCondition::create([
    'permission_id' => $permission->id,
    'name' => 'owner-only',
    'conditions' => [
        'field' => 'user.id',
        'operator' => '=',
        'value' => 'resource.owner_id',
    ],
]);
Enter fullscreen mode Exit fullscreen mode

Then check it against a specific resource:

$user->canPermission('posts.edit', $post);
// Returns true ONLY if $user->id === $post->owner_id
Enter fullscreen mode Exit fullscreen mode

Complex Conditions

Real-world rules are rarely simple:

// Rule: Users can edit posts they own, 
// BUT only if the post is in draft status,
// OR if the user is a department manager in the same department
PermissionCondition::create([
    'conditions' => [
        'any' => [
            // Condition 1: Owner + Draft
            ['all' => [
                ['field' => 'user.id', 'operator' => '=', 'value' => 'resource.owner_id'],
                ['field' => 'resource.status', 'operator' => '=', 'value' => 'draft'],
            ]],
            // Condition 2: Department manager
            ['all' => [
                ['field' => 'user.role', 'operator' => '=', 'value' => 'department-manager'],
                ['field' => 'user.department_id', 'operator' => '=', 'value' => 'resource.department_id'],
            ]],
        ],
    ],
]);
Enter fullscreen mode Exit fullscreen mode

Supported Operators

A good condition engine supports:

Category Operators
Equality =, !=
Comparison >, >=, <, <=
Collections in, not_in, contains
Strings starts_with, ends_with
Existence exists, not_exists

Security Note

⚠️ Critical: The condition engine must be whitelist-based, never using eval() or dynamic code execution. JSON rules are interpreted, not executed.


🔴 Problem #5: The Silent Cache Bug

The Symptom

You change a role's permissions. Users still see old permissions for hours. Support tickets flood in.

Or worse: you revoke a user's admin role, but they can still access admin routes because the cache wasn't invalidated.

Why Naive Caching Fails

Basic cache strategies only invalidate the changed entity:

$role->assignPermission('users.delete');
$role->forgetCachedPermissions(); // Only clears role cache!
Enter fullscreen mode Exit fullscreen mode

But what about the 500 users who have this role? Their user-level cache still says they don't have users.delete.

The Solution: Hierarchical Cache Invalidation

Modern systems use cascading invalidation:

// When you change a role's permission:
$role->forgetCachedPermissions();

// This should:
// 1. Clear the role's own cache
// 2. Clear cache for ALL users who have this role
// 3. Clear cache for roles that INHERIT from this role
// 4. Clear cache for users of those inherited roles
Enter fullscreen mode Exit fullscreen mode

Cache Tags (When Available)

With Redis or Memcached, use cache tags for surgical invalidation:

Cache::tags(['role:42'])->flush();        // Everything tagged with role 42
Cache::tags(['user:123'])->flush();       // Everything for user 123
Cache::tags(['permissions:global'])->flush(); // Global permission list
Enter fullscreen mode Exit fullscreen mode

Cache Warming

For production deployments, warm the cache proactively:

php artisan permission:cache:warm
# Preloads all role permissions, user permissions, etc.
Enter fullscreen mode Exit fullscreen mode

🌟 Bonus: The 3 AM Debugging Nightmare

The Symptom

It's 3 AM. A user reports they can't access a feature they should have access to. You check:

  • ✅ User has the editor role
  • editor role has posts.publish permission
  • ✅ User is logged in
  • ❌ But hasPermissionTo('posts.publish') returns false

Why?!

The Solution: Explain API

Modern authorization systems include an Explain API that shows exactly why a decision was made:

$ php artisan permission:why 42 posts.publish

┌─────────────────────────────────────────────────┐
│  Permission Decision Explanation                │
└─────────────────────────────────────────────────┘

  User:    42 (John Doe)
  Ability: posts.publish

  ✗ DENIED

  Reason:  explicit_user_deny
  Source:  direct_permission
  Details:
    - matched_pattern: posts.publish
    - permission_id: 15
    - effect: deny
    - expires_at: null

  Resolution chain:
    ✓ Role 'editor' allows posts.* (would normally grant)
    ✗ User has explicit DENY on posts.publish
    → Final decision: DENY (explicit deny wins)
Enter fullscreen mode Exit fullscreen mode

Suddenly it's obvious: someone added a direct deny permission to this specific user. Maybe during testing. Maybe by mistake. Now you know.

Programmatic Access

$result = PermissionManager::explain($user, 'posts.publish');

// Returns:
[
    'allowed' => false,
    'ability' => 'posts.publish',
    'reason' => 'explicit_user_deny',
    'source' => 'direct_permission',
    'metadata' => [
        'matched_pattern' => 'posts.publish',
        'permission_id' => 15,
    ],
    'user' => [
        'id' => 42,
        'roles' => ['editor'],
        'direct_permissions' => ['!posts.publish'],
    ],
]
Enter fullscreen mode Exit fullscreen mode

Health Check Command

Go one step further with a "doctor" command:

$ php artisan permission:doctor

✓ No orphan permissions
✓ No orphan roles
✗ Cyclic role inheritance detected: A → B → A
✓ No duplicate roles
⚠ 15 expired permissions can be pruned
✓ Cache consistency OK
Enter fullscreen mode Exit fullscreen mode

🎁 The Complete Solution

All five problems (and the bonus one) are solved by a single, cohesive package:

📦 Laravel Permission Manager v2.0

An enterprise-grade authorization engine that combines:

  • RBAC + Direct Permissions + Allow/Deny
  • Role Hierarchy with multi-level inheritance
  • Teams / Multi-Tenancy
  • ABAC with JSON-based Condition Engine
  • Audit Logging for all changes
  • Multi-Guard support
  • Temporary Permissions with auto-expiry
  • Explain API and CLI Doctor
  • 141 passing tests with 226 assertions
  • 100% backward compatible with v1

Quick Installation

composer require hosseinhezami/laravel-permission-manager
php artisan vendor:publish --provider="HosseinHezami\PermissionManager\PermissionManagerServiceProvider" --tag="migrations"
php artisan migrate
Enter fullscreen mode Exit fullscreen mode

Add Trait

use HosseinHezami\PermissionManager\Traits\PermissionTrait;

class User extends Authenticatable
{
    use PermissionTrait;
}
Enter fullscreen mode Exit fullscreen mode

💡 Real-World Implementation

Let's see how all these patterns work together in a real SaaS application.

Scenario: Multi-Tenant Project Management Tool

// Setup teams
$acme = Team::createTeam(['name' => 'Acme Corp']);
$globex = Team::createTeam(['name' => 'Globex Inc']);

// Setup role hierarchy
$viewer = Role::create(['name' => 'Viewer']);
$editor = Role::create(['name' => 'Editor']);
$admin = Role::create(['name' => 'Admin']);

$editor->inheritFrom('viewer');
$admin->inheritFrom('editor');

// Assign permissions
$viewer->assignPermission('projects.view');
$editor->assignPermission(['projects.edit', 'tasks.create']);
$admin->assignPermission(['projects.delete', 'members.manage']);

// A contractor joins both companies
$contractor = User::create([...]);
$contractor->joinTeam($acme);
$contractor->joinTeam($globex);

// Different roles per company
$contractor->assignRoleForTeam('admin', $acme);
$contractor->assignRoleForTeam('editor', $globex);

// Temporary 30-day access
$contractor->givePermissionTo(
    'billing.view', 
    'allow', 
    now()->addDays(30)
);

// Conditional permission: editors can only edit their own tasks
PermissionCondition::create([
    'permission_id' => Permission::findByRoute('tasks.edit')->id,
    'conditions' => [
        'all' => [
            ['field' => 'user.id', 'operator' => '=', 'value' => 'resource.assignee_id'],
            ['field' => 'resource.status', 'operator' => '!=', 'value' => 'completed'],
        ],
    ],
]);
Enter fullscreen mode Exit fullscreen mode

Controller Example

class TaskController extends Controller
{
    public function update(Request $request, Team $team, Task $task)
    {
        // Set team context
        PermissionManager::setTeam($team);

        // ABAC check with resource
        if (!auth()->user()->canPermission('tasks.edit', $task)) {
            abort(403, 'You cannot edit this task');
        }

        $task->update($request->validated());

        // Audit log is automatic!
        return response()->json(['status' => 'updated']);
    }
}
Enter fullscreen mode Exit fullscreen mode

Middleware Example

Route::middleware(['auth', 'pm.team:route,team', 'pm:role:admin|editor'])->group(function () {
    Route::get('/projects', [ProjectController::class, 'index']);
    Route::post('/projects', [ProjectController::class, 'store'])
        ->middleware('pm:permission:projects.create');
});
Enter fullscreen mode Exit fullscreen mode

Blade Example

@role('admin')
    <a href="{{ route('admin.settings') }}">Admin Settings</a>
@endrole

@canpermission('tasks.edit', $task)
    <button @click="editTask({{ $task->id }})">Edit</button>
@endcanpermission

@hasanyrole(['admin', 'editor'])
    <div class="editor-toolbar">...</div>
@endhasanyrole
Enter fullscreen mode Exit fullscreen mode

📊 Comparison with Spatie

Spatie's laravel-permission is the gold standard with 110M+ downloads. Here's how Laravel Permission Manager v2.0 compares:

Feature Laravel Permission Manager Spatie Permission
RBAC
Direct Permissions
Wildcard Permissions ✅ (advanced + negation)
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
CLI Why/Explain
Permission Tree
Resource Generator
Route Sync ✅ (3 strategies)
Permission Groups
Permission Sets
Gate Integration ✅ Native
Middleware DSL ✅ any/all/not
Blade Directives ✅ 12+
Smart Caching ✅ Hierarchical
Testing Helpers ✅ Built-in
Events ✅ 6 events
Import/Export ✅ JSON
Tests 141 ~300
Laravel 13 Support ⚠️
Backward Compatible ✅ 100%

When to Choose Spatie

  • ✅ Massive community support
  • ✅ Extensive documentation
  • ✅ Proven in production for years
  • ✅ Lots of third-party integrations

When to Choose Laravel Permission Manager

  • ✅ You need role hierarchy (Spatie doesn't have it)
  • ✅ You need explicit deny (critical for exceptions)
  • ✅ You need ABAC / conditional permissions
  • ✅ You need built-in audit logging
  • ✅ You need to debug why access was denied
  • ✅ You want modern CLI tools (doctor, why, explain, tree)
  • ✅ You need temporary permissions with auto-expiry
  • ✅ You want 100% backward compatibility with v1

💭 Final Thoughts

Authorization is one of those problems that looks simple on day 1 and becomes a monster by month 12.

The five problems we explored — role explosion, exception handling, multi-tenancy, contextual permissions, and cache bugs — aren't edge cases. They're the inevitable evolution of every serious Laravel application.

The good news? You don't have to solve them one by one with custom code scattered across your codebase. Modern authorization packages like Laravel Permission Manager give you all the patterns in a single, cohesive system.

Key Takeaways

  1. Pure RBAC isn't enough — you need direct permissions and explicit deny
  2. Roles explode without deny rules!posts.delete beats creating 50 roles
  3. Multi-tenancy requires team context — global roles don't work for SaaS
  4. ABAC handles contextual rules — JSON conditions, no eval(), no mess
  5. Cache invalidation must be hierarchical — or you'll ship stale permissions
  6. Debug tools are non-negotiable — the Explain API saves 3 AM debugging sessions

Next Steps

If any of these problems sound familiar, give Laravel Permission Manager a try:

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

It supports Laravel 10, 11, 12, and 13, works with PHP 8.2+, and is 100% backward compatible — so it won't break your existing code.


🔗 GitHub Repository · 📦 Packagist


Tags: #laravel #php #authorization #rbac #abac #security #webdev #tutorial #opensource #saas


Found this helpful? Star ⭐ the GitHub repo and share it with your team!

Top comments (0)