DEV Community

Cover image for 7 Authorization Architectures in the Wild — How to Pick the Right One for Your Laravel App
Hossein Hezami
Hossein Hezami

Posted on

7 Authorization Architectures in the Wild — How to Pick the Right One for Your Laravel App

TL;DR: Not every Laravel app needs the same authorization system. A blog needs one thing, a SaaS needs another, a fintech needs a third. This guide walks through 7 real-world authorization architectures I've seen in production Laravel apps, with code, trade-offs, and a decision matrix to pick the right one. The last one — the Hybrid Enterprise Engine — is what Laravel Permission Manager implements.

🔗 GitHub Repository · 📦 Packagist


📋 Table of Contents


🤔 Why Architecture Matters

Walk into any Laravel team and ask "how do you do authorization?" — you'll get a different answer every time.

Some say "we use Gates." Others say "we use Spatie." Others whisper "we have a giant if tree in our User model."

Here's the uncomfortable truth: most Laravel teams don't choose an authorization architecture. They drift into one. A Gate::define here, a policy there, some middleware somewhere — and two years later they have a system nobody fully understands.

The cost of drifting:

  • 🔐 Security gaps nobody noticed
  • 🐛 Bugs that take weeks to debug
  • 🧠 Knowledge silos — only one dev "knows how permissions work"
  • 📈 Scalability walls that appear suddenly

This guide gives you 7 distinct architectures, each proven in production Laravel apps. By the end, you'll know exactly which one fits your project — and when to upgrade to the next.


🏛️ Architecture #1: The Gate-Only Approach

The pattern

Use Laravel's built-in Gate facade and Gate::define for every check. No package, no database tables, just closures.

// AuthServiceProvider.php
Gate::define('edit-post', function ($user, $post) {
    return $user->id === $post->user_id;
});

Gate::define('delete-post', function ($user, $post) {
    return $user->is_admin || $user->id === $post->user_id;
});

Gate::define('view-dashboard', function ($user) {
    return $user->role === 'admin';
});
Enter fullscreen mode Exit fullscreen mode

Usage:

if (Gate::allows('edit-post', $post)) { ... }
Enter fullscreen mode Exit fullscreen mode

Where it works

  • 📝 Blogs, portfolios, personal sites
  • 🎯 Apps with fewer than ~15 permissions
  • 👤 Solo developers who know the whole system
  • 🧪 Prototypes and MVPs

Where it breaks

  • No persistence: permissions live in code, can't be changed at runtime
  • No admin UI: you can't give someone a permission without deploying
  • Duplication: every gate is custom — no shared "editor" concept
  • Audit impossibility: no record of who changed what

The breaking point

When someone asks "Can we add a manager role that has everything editors have plus posts.publish?" — you're rewriting dozens of gates.


🏛️ Architecture #2: The Spatie-Style RBAC

The pattern

Classic Role-Based Access Control stored in the database:

User ←[n:m]→ Role ←[n:m]→ Permission
Enter fullscreen mode Exit fullscreen mode

This is the most popular pattern in the Laravel ecosystem, popularized by Spatie.

$user->assignRole('editor');
$role->givePermissionTo('posts.edit');

$user->hasPermissionTo('posts.edit'); // true
$user->can('edit posts');             // via Gate integration
Enter fullscreen mode Exit fullscreen mode

Where it works

  • 🏢 Most B2B SaaS applications
  • 👥 Apps with clear role definitions
  • 🔧 Apps where admins manage permissions via UI

Where it breaks

The moment requirements include exceptions or context, Spatie-style RBAC hits walls:

// Requirement: "Editor can edit posts, but not their own"
// Spatie has no way to express this cleanly
Enter fullscreen mode Exit fullscreen mode

The breaking point

You end up with either:

  • Role explosion: editor, editor-no-publish, editor-no-delete, senior-editor-drafts-only
  • Scattered logic: special-cases in controllers and policies that aren't in the DB

🏛️ Architecture #3: The Policy-Per-Model Pattern

The pattern

One policy class per Eloquent model, using Laravel's native authorization:

class PostPolicy
{
    public function view($user, $post) { ... }
    public function update($user, $post) {
        return $user->id === $post->user_id
            || $user->hasRole('editor');
    }
    public function delete($user, $post) {
        return $user->hasRole('admin');
    }
}
Enter fullscreen mode Exit fullscreen mode

Usage:

$user->can('update', $post);
Gate::authorize('delete', $post);
Enter fullscreen mode Exit fullscreen mode

Where it works

  • 🎯 Resource-heavy apps (CRUD on many models)
  • 📚 Apps with clear model ownership
  • 🏗️ Well-structured DDD codebases

Where it breaks

  • No cross-cutting permissions: reports.export doesn't belong to any model
  • Duplication: "is admin" logic repeats in 40 policies
  • Drift: policies diverge from each other over time
  • Untestable at scale: you need a test per policy method

The breaking point

"A user can only edit tasks in their department's projects, and only if they're assigned, and only if the project isn't archived."

Suddenly every policy needs the same 5-line check. You've built a shadow authorization system.


🏛️ Architecture #4: Route-Based Authorization

The pattern

Every Laravel route is a permission. Middleware checks the current route name against the user's permissions.

// Middleware
public function handle($request, Closure $next)
{
    $route = Route::currentRouteName();
    if (!$request->user()->hasPermissionTo($route)) {
        abort(403);
    }
    return $next($request);
}
Enter fullscreen mode Exit fullscreen mode
// Sync from Laravel's route table
php artisan permission:sync-routes
Enter fullscreen mode Exit fullscreen mode

Where it works

  • 🛣️ Admin panels with lots of routes
  • 🔄 Apps where permissions map 1:1 to routes
  • 🏭 Large teams where routes are the shared vocabulary

Where it breaks

  • Can't express "edit this post" — it's binary
  • Over-permissioned: having users.edit route means editing any user
  • Route renames break permissions: refactor = permission migration

The breaking point

Any contextual rule — ownership, status, time-based access — doesn't fit. Route-based authorization is the floor, not the ceiling.


🏛️ Architecture #5: Hierarchical RBAC

The pattern

Classic RBAC with inheritance: roles extend other roles, permissions cascade.

$viewer->assignPermission('posts.view');
$editor->assignPermission('posts.edit');
$admin->assignPermission('users.manage');

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

$user->assignRole('admin');
$user->hasPermissionTo('posts.view'); // ✅ inherited from viewer
Enter fullscreen mode Exit fullscreen mode

Where it works

  • 🏛️ Apps with clear org charts
  • 🎓 Education platforms (student < TA < instructor < dean)
  • 💼 Enterprise apps where "admin > manager > user" is intuitive

Where it breaks

  • Cycle traps: A → B → A breaks apps without detection
  • Diamond problem: what if a role inherits from two parents with conflicting permissions?
  • Cache invalidation: change a leaf role, all descendants must invalidate
  • Deny semantics: inheritance can't express "admin, except for X"

The breaking point

"Admins inherit everything from editors, except posts.delete."

Pure hierarchy can't express negative inheritance. You need explicit deny rules layered on top.


🏛️ Architecture #6: ABAC / Condition-Based Authorization

The pattern

Attribute-Based Access Control. Decisions are made by evaluating attributes of the user, resource, and environment against declarative rules.

// Rule: "Users can edit posts they own, while in draft status"
PermissionCondition::create([
    'permission_id' => $permission->id,
    'conditions' => [
        'all' => [
            ['field' => 'user.id', 'operator' => '=', 'value' => 'resource.user_id'],
            ['field' => 'resource.status', 'operator' => '=', 'value' => 'draft'],
        ],
    ],
]);

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

Where it works

  • 🏦 Fintech, healthcare, compliance-heavy domains
  • 🌐 Multi-tenant SaaS with shared data
  • 🛒 Marketplaces where "owner" logic is everywhere
  • 📜 Apps subject to SOC 2, ISO 27001, HIPAA

Where it breaks

  • Complexity: simple "admin can do everything" becomes a rule
  • Debug difficulty: why did a condition fail? What attribute was wrong?
  • Performance: evaluating many conditions on every check
  • Security: naive implementations with eval() are dangerous

The breaking point

Pure ABAC is overkill for most apps. Used alone, it becomes unreadable. Used layered on top of RBAC, it's the gold standard.


🏛️ Architecture #7: The Hybrid Enterprise Engine

The pattern

Combine every architecture above into a layered authorization engine, where each check flows through a deterministic resolution order:

1. Super Admin bypass
2. Explicit User DENY           ← highest priority
3. Explicit User ALLOW
4. Role DENY
5. Role ALLOW
6. Inherited Role permissions
7. ABAC / Condition evaluation
8. Wildcard resolution
9. Default: DENY                ← lowest
Enter fullscreen mode Exit fullscreen mode

Each layer answers a specific class of question:

Layer Answers
RBAC "What kind of user is this?"
Direct permissions "Any exceptions for this user?"
Hierarchy "What do they inherit?"
ABAC "Under what conditions?"
Teams "In which tenant?"
Audit "Who changed what, when?"
Explain "Why was this denied?"

The code

// Simple case — works like Spatie
$user->assignRole('editor');
$user->hasPermissionTo('posts.edit'); // ✅

// With hierarchy
$editor->inheritFrom('viewer');
$user->hasPermissionTo('posts.view'); // ✅ inherited

// With explicit deny
$user->denyPermissionTo('posts.delete');
$user->hasPermissionTo('posts.delete'); // ❌ deny wins

// With ABAC
$user->canPermission('posts.edit', $post); // ✅ owner + draft

// With teams
$user->assignRoleForTeam('admin', $acme);
$user->assignRoleForTeam('viewer', $globex);

// With debugging
PermissionManager::explain($user, 'posts.delete');
// ['allowed' => false, 'reason' => 'explicit_user_deny', ...]
Enter fullscreen mode Exit fullscreen mode

Where it works

  • 🏢 Enterprise SaaS with complex requirements
  • 🌐 Multi-tenant platforms
  • 🏦 Regulated industries
  • 📈 Apps that will scale from MVP to enterprise

The trade-off

  • ⚖️ Complexity: more moving parts than simpler architectures
  • 📚 Learning curve: team needs to understand layers
  • 🏗️ Setup: more migrations, more config

Why it's worth it

Every simpler architecture eventually migrates here. The hybrid engine isn't for day-one apps — it's for day-100 apps that have outgrown every simpler pattern.


🎯 The Decision Matrix

Pick your architecture based on your project's reality:

Signal Recommended Architecture
Solo dev, <10 permissions, blog/portfolio #1 Gate-Only
Clear roles, <50 permissions, small SaaS #2 Spatie-style RBAC
CRUD-heavy app with model ownership #3 Policy-per-Model
Admin panel with many routes #4 Route-Based
Clear hierarchy, few exceptions #5 Hierarchical RBAC
Context-heavy rules (owner, status, dept) #6 ABAC
Multi-tenant, regulated, or scaling SaaS #7 Hybrid Enterprise

Or, more pragmatically:

If you've ever said... You need...
"Just a simple role check" #1 or #2
"We need a manager-no-delete role" #2 with deny rules, or #7
"Users should only edit their own stuff" #3 or #6
"Admins have different rights per company" #7 (with teams)
"We can't debug why access was denied" #7 (with Explain API)
"We need an audit trail for compliance" #7 (with audit logging)

🔁 How to Migrate Between Architectures

The good news: migrations are usually additive, not replacements.

From #1 Gates to #2 RBAC

  • Keep the gates
  • Add the package
  • Register permissions in DB
  • Gradually migrate gates to hasPermissionTo

From #2 RBAC to #5 Hierarchical

  • Keep all roles and permissions
  • Add role_inherits table
  • Add inheritFrom() calls
  • Existing checks keep working

From #5 to #7 Hybrid

  • Keep everything
  • Add explicit deny, ABAC, teams, audit
  • Resolution order handles conflicts automatically

The Laravel Permission Manager is designed so you can start at #2 and migrate toward #7 without breaking existing code:

// Day 1: Simple RBAC
$user->assignRole('editor');

// Day 100: Add deny
$user->denyPermissionTo('posts.delete');

// Day 200: Add hierarchy
$editor->inheritFrom('viewer');

// Day 300: Add ABAC
PermissionCondition::create([...]);

// Day 400: Add teams
$user->assignRoleForTeam('admin', $acme);
Enter fullscreen mode Exit fullscreen mode

Every step is backward compatible. The architecture grows with your app.


💭 Final Thoughts

The biggest mistake I see in Laravel authorization isn't picking the wrong architecture — it's not picking one at all.

Teams drift. They mix Gates with policies with scattered middleware with a role table bolted on. Six months later, nobody can explain why user X can or cannot do Y.

The fix is simple:

  1. Know the 7 architectures. Each has its place.
  2. Pick one deliberately based on your project's real needs.
  3. Document the choice so the next developer knows why.
  4. Plan the migration path — most apps evolve from #1 → #2 → #7 over time.

If your app is at the point where simpler patterns are showing cracks — role explosion, scattered policies, impossible debug sessions, compliance questions — then the Hybrid Enterprise Engine is probably your next step.

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

And remember: the best authorization architecture is the one your team can explain in a single sentence.


🔗 GitHub Repository · 📦 Packagist


Tags: #laravel #php #architecture #authorization #rbac #abac #security #webdev #saas #opensource #backend #softwarearchitecture

Top comments (0)