Laravel Role and Permission System From Scratch vs Spatie: I Built Both. Here’s What I’d Actually Do.

The from-scratch version taught me what Spatie actually does under the hood. The Spatie version showed me what I’d have missed building alone. Here’s the complete comparison — schema design, middleware, Blade directives, API guards, and the one scenario where rolling your own still makes sense.


The “should I use Spatie or build my own?” question appears in every Laravel project that needs more than a single admin flag. The honest answer depends on which version of the question you’re actually asking. If the question is “which produces better production software?”, Spatie wins almost every time. If the question is “which produces better understanding?”, building from scratch first is the more instructive path. I did both, in that order. This post is what I learned from each — the from-scratch version’s design, where Spatie improves on it, the specific things the from-scratch version would have gotten wrong, and the one category of application where custom still makes sense.


The From-Scratch Version

Schema Design

The schema for a from-scratch permission system has four tables:

// 1. Roles — named groups of permissions
Schema::create('roles', function (Blueprint $table) {
    $table->id();
    $table->string('name')->unique();        // 'admin', 'editor', 'viewer'
    $table->string('display_name');          // 'Administrator', 'Content Editor'
    $table->string('description')->nullable();
    $table->timestamps();
});

// 2. Permissions — individual capabilities
Schema::create('permissions', function (Blueprint $table) {
    $table->id();
    $table->string('name')->unique();        // 'edit-posts', 'delete-users'
    $table->string('display_name');
    $table->string('group')->nullable();     // 'content', 'users', 'billing'
    $table->timestamps();
});

// 3. Role-Permission pivot
Schema::create('role_permission', function (Blueprint $table) {
    $table->foreignId('role_id')->constrained()->cascadeOnDelete();
    $table->foreignId('permission_id')->constrained()->cascadeOnDelete();
    $table->primary(['role_id', 'permission_id']);
});

// 4. User-Role pivot (users can have multiple roles)
Schema::create('role_user', function (Blueprint $table) {
    $table->foreignId('user_id')->constrained()->cascadeOnDelete();
    $table->foreignId('role_id')->constrained()->cascadeOnDelete();
    $table->primary(['user_id', 'role_id']);
});

Notice what’s missing: there’s no direct user-permission pivot. In the initial design, permissions are assigned to roles, and roles are assigned to users. Spatie adds user-direct-permission assignment — the ability to give a specific user a permission that isn’t covered by their role. Whether you need this depends on your authorization model.

The Model Trait

// app/Traits/HasRolesAndPermissions.php
trait HasRolesAndPermissions
{
    public function roles(): BelongsToMany
    {
        return $this->belongsToMany(Role::class);
    }

    public function hasRole(string|Role $role): bool
    {
        $roleName = $role instanceof Role ? $role->name : $role;
        return $this->roles->contains('name', $roleName);
    }

    public function assignRole(string|Role $role): void
    {
        $roleModel = $role instanceof Role
            ? $role
            : Role::where('name', $role)->firstOrFail();

        $this->roles()->syncWithoutDetaching([$roleModel->id]);
    }

    public function removeRole(string|Role $role): void
    {
        $roleModel = $role instanceof Role
            ? $role
            : Role::where('name', $role)->first();

        if ($roleModel) {
            $this->roles()->detach($roleModel->id);
        }
    }

    public function hasPermission(string $permission): bool
    {
        return $this->roles->flatMap(fn ($role) =>
            $role->permissions->pluck('name')
        )->contains($permission);
    }

    public function can($permission, $arguments = []): bool
    {
        // Override Laravel's built-in can() to check our permission system
        return $this->hasPermission($permission)
            || parent::can($permission, $arguments);
    }
}

The Middleware

// app/Http/Middleware/CheckRole.php
class CheckRole
{
    public function handle(Request $request, Closure $next, string ...$roles): Response
    {
        if (!$request->user()) {
            abort(401);
        }

        foreach ($roles as $role) {
            if ($request->user()->hasRole($role)) {
                return $next($request);
            }
        }

        abort(403, 'You do not have the required role.');
    }
}

// app/Http/Middleware/CheckPermission.php
class CheckPermission
{
    public function handle(Request $request, Closure $next, string ...$permissions): Response
    {
        if (!$request->user()) {
            abort(401);
        }

        foreach ($permissions as $permission) {
            if (!$request->user()->hasPermission($permission)) {
                abort(403, "Missing permission: {$permission}");
            }
        }

        return $next($request);
    }
}
// bootstrap/app.php (Laravel 11)
->withMiddleware(function (Middleware $middleware) {
    $middleware->alias([
        'role'       => CheckRole::class,
        'permission' => CheckPermission::class,
    ]);
})

Blade Directives

// AppServiceProvider::boot()
Blade::if('role', function (string $role) {
    return auth()->check() && auth()->user()->hasRole($role);
});

Blade::if('permission', function (string $permission) {
    return auth()->check() && auth()->user()->hasPermission($permission);
});
@role('admin')
    <a href="/admin">Admin Panel</a>
@endrole

@permission('edit-posts')
    <button>Edit Post</button>
@endpermission

Where the From-Scratch Version Works

For a simple application with fixed roles (admin, editor, viewer), no multi-tenancy, and no need for direct user permissions, the from-scratch version is fully functional. It’s around 200 lines of code across three files. It’s easy to understand. It does exactly what it says.

Where It Falls Apart

Problem 1: The N+1 query in hasPermission()

public function hasPermission(string $permission): bool
{
    return $this->roles->flatMap(fn ($role) =>
        $role->permissions->pluck('name')
    )->contains($permission);
}

Every call to hasPermission() loads $this->roles (if not loaded) and then loads $role->permissions for each role (if not loaded). In a request that checks five permissions for a user with three roles, that’s potentially 1 + 3 = 4 queries just for permission resolution — and that’s per can() call. In a Blade template that calls @permission six times, you have 24 queries.

Spatie caches the full permission set for the user as a flat collection on first resolve. Subsequent checks hit the collection. Zero additional queries.

Problem 2: No guard support

The from-scratch version has no concept of guards. In an application with both a web guard (session-based) and an api guard (token-based), roles need to be associated with the guard under which they apply. A user with the admin role in the web guard might have a different role set for API access.

Spatie’s tables include a guard_name column on both roles and permissions. The from-scratch version would need to be retrofitted for this.

Problem 3: No cache invalidation

The from-scratch version loads permissions on every call. Adding a Redis cache requires implementing cache invalidation correctly — clearing on role assignment, on permission assignment, on role removal. This is a non-trivial implementation that Spatie provides out of the box.

Problem 4: Direct user permissions

The from-scratch version doesn’t support assigning a permission directly to a user without going through a role. This is the pattern:

// Not possible with the from-scratch implementation
$user->givePermissionTo('publish-articles');
// Publish permission granted to this specific user
// without changing their role

This requires a model_has_permissions pivot that the from-scratch schema doesn’t have.


The Spatie Version

composer require spatie/laravel-permission
php artisan vendor:publish --provider="Spatie\Permission\PermissionServiceProvider"
php artisan migrate

The tables Spatie creates:

roles                   → id, name, guard_name, timestamps
permissions             → id, name, guard_name, timestamps
model_has_roles         → role_id, model_type, model_id
model_has_permissions   → permission_id, model_type, model_id
role_has_permissions    → permission_id, role_id

Five tables vs four in the from-scratch version. The additions: model_type (polymorphic — works with any model, not just User), guard_name (multi-guard support), and model_has_permissions (direct user permissions).

What Spatie Does That the From-Scratch Version Doesn’t

Cache with correct invalidation:

// config/permission.php
'cache' => [
    'expiration_time' => \DateInterval::createFromDateString('24 hours'),
    'key'             => 'spatie.permission.cache',
    'store'           => 'default',
],

Spatie caches the entire permission lookup for a model as a flat array on first resolution. Every subsequent hasPermissionTo() call hits the cache. The cache is automatically cleared when roles or permissions are modified via Spatie’s methods.

// This is what Spatie's hasPermissionTo() does internally (simplified):
public function hasPermissionTo(string $permission): bool
{
    // First call: queries database, stores in collection
    // Subsequent calls: reads from in-memory collection
    $permissions = $this->getPermissionsViaRoles();

    return $permissions->contains('name', $permission);
}

Multi-guard awareness:

// Roles are guard-scoped
$adminRole = Role::create(['name' => 'admin', 'guard_name' => 'web']);
$apiRole   = Role::create(['name' => 'admin', 'guard_name' => 'api']);

// Same role name, different guard contexts
$user->assignRole('admin'); // assigns web guard's 'admin'

// In API context
auth()->guard('api')->user()->hasRole('admin'); // checks api guard's 'admin'

Direct user permissions:

// Assign a permission directly to a user
$user->givePermissionTo('publish-articles');

// Check works whether the permission comes from a role or direct assignment
$user->hasPermissionTo('publish-articles'); // true

// Check if it came from a role specifically
$user->hasDirectPermission('publish-articles'); // true
$user->hasPermissionViaRole('publish-articles'); // false

// Remove direct permission
$user->revokePermissionTo('publish-articles');

Wildcards and permission scoping (in Spatie 6.x):

// Create permissions with dot notation
Permission::create(['name' => 'articles.create']);
Permission::create(['name' => 'articles.edit']);
Permission::create(['name' => 'articles.delete']);

// Check specific permission
$user->hasPermissionTo('articles.create');

The @can Blade directive works automatically:

Unlike the from-scratch version where you register custom @role and @permission directives, Spatie hooks into Laravel’s Gate. The built-in @can directive works:

@can('edit-posts')
    <button>Edit Post</button>
@endcan

{{-- Spatie also provides its own directives --}}
@role('admin')
    <a href="/admin">Admin Panel</a>
@endrole

@hasanyrole(['admin', 'editor'])
    <nav>Management Menu</nav>
@endhasanyrole

Side-by-Side: The Same Feature, Both Ways

Checking if a user can access an admin panel

From scratch:

// Middleware
Route::middleware(['auth', 'role:admin'])->group(function () {
    Route::get('/admin', [AdminController::class, 'index']);
});

// Policy
public function viewAdminPanel(User $user): bool
{
    return $user->hasRole('admin') || $user->hasRole('super-admin');
}

// Blade
@role('admin')
    <a href="/admin">Admin</a>
@endrole

Spatie:

// Middleware — identical syntax
Route::middleware(['auth', 'role:admin'])->group(function () {
    Route::get('/admin', [AdminController::class, 'index']);
});

// Policy — can use Gate directly since Spatie integrates with it
public function viewAdminPanel(User $user): bool
{
    return $user->hasRole(['admin', 'super-admin']);
    // Spatie's hasRole() accepts arrays natively
}

// Blade — same Spatie directive
@role('admin')
    <a href="/admin">Admin</a>
@endrole

The syntax is nearly identical. The difference is in what’s happening underneath: the from-scratch version queries the database on each check; Spatie resolves from cache.

Adding a temporary permission to one user

From scratch:

// No direct user permissions in the from-scratch schema.
// Option 1: Create a special role for this user
$tempRole = Role::firstOrCreate(['name' => 'temp-publisher-user-42']);
$tempRole->permissions()->attach(Permission::where('name', 'publish-articles')->first());
$user->assignRole($tempRole);

// Option 2: Add a method that checks a separate table you'd need to build
// Either way: significant work not in the original design

Spatie:

// One line
$user->givePermissionTo('publish-articles');

// Later:
$user->revokePermissionTo('publish-articles');

This is the scenario where the from-scratch version’s schema decision (no user-permission pivot) becomes a problem.

API guard permissions

From scratch:

// The from-scratch middleware checks auth()->user()
// which resolves based on the default guard
// In API routes, this may be the wrong guard

// You'd need to modify the middleware to be guard-aware:
class CheckPermission
{
    public function handle(Request $request, Closure $next, string ...$permissions): Response
    {
        $user = auth()->guard($request->header('X-Guard', 'api'))->user();
        // ... this gets complicated
    }
}

Spatie:

// Spatie is guard-aware out of the box
// In API routes using Sanctum:
Route::middleware(['auth:sanctum', 'permission:manage-api-resources'])->group(function () {
    // Spatie checks permissions against the 'api' guard automatically
});

The Seeder Pattern — Both Versions

A permission seeder that works the same way in both implementations (with minor syntax differences):

class RoleAndPermissionSeeder extends Seeder
{
    public function run(): void
    {
        // From scratch: use your model factories
        // Spatie: use Spatie's methods + reset cache first
        app()[\Spatie\Permission\PermissionRegistrar::class]->forgetCachedPermissions();

        $permissions = [
            // Content
            'view-posts', 'create-posts', 'edit-posts', 'delete-posts', 'publish-posts',

            // Users
            'view-users', 'create-users', 'edit-users', 'delete-users',

            // Billing
            'view-billing', 'manage-billing', 'view-invoices',

            // Admin
            'access-admin', 'manage-roles', 'manage-permissions',
        ];

        foreach ($permissions as $permission) {
            Permission::firstOrCreate(['name' => $permission]);
        }

        // Viewer — read only
        $viewer = Role::firstOrCreate(['name' => 'viewer']);
        $viewer->syncPermissions(['view-posts', 'view-billing', 'view-invoices']);

        // Editor — content management
        $editor = Role::firstOrCreate(['name' => 'editor']);
        $editor->syncPermissions([
            'view-posts', 'create-posts', 'edit-posts',
            'view-billing', 'view-invoices',
        ]);

        // Admin — full access except permission management
        $admin = Role::firstOrCreate(['name' => 'admin']);
        $admin->syncPermissions(
            Permission::whereNotIn('name', ['manage-roles', 'manage-permissions'])->pluck('name')
        );

        // Super admin — everything
        $superAdmin = Role::firstOrCreate(['name' => 'super-admin']);
        $superAdmin->syncPermissions(Permission::all());
    }
}

The Cache Configuration That Makes Spatie Fast

Without the cache configured properly, Spatie queries the database on every hasPermissionTo() call — the same behaviour as the from-scratch version. The cache is what makes it production-viable.

// config/permission.php
'cache' => [
    // How long to cache permissions
    'expiration_time' => \DateInterval::createFromDateString('24 hours'),

    // The cache key
    'key' => 'spatie.permission.cache',

    // Which cache store to use (use Redis in production)
    'store' => env('PERMISSION_CACHE_STORE', 'default'),
],
# .env
PERMISSION_CACHE_STORE=redis

Clear the cache when you modify roles or permissions:

php artisan permission:cache-reset

Or programmatically:

app()[\Spatie\Permission\PermissionRegistrar::class]->forgetCachedPermissions();

Spatie calls this automatically when you use its syncPermissions(), givePermissionTo(), or assignRole() methods. If you modify the database directly (via Tinker or a seeder that uses query builder), you need to clear it manually.


Testing Both Versions

The testing surface is similar:

// tests/Feature/RolePermissionTest.php

// Works for both from-scratch and Spatie (Spatie uses same assertion style)
it('admin can access the admin panel', function () {
    $admin = User::factory()->create();
    $admin->assignRole('admin');

    $this->actingAs($admin)
         ->get('/admin')
         ->assertOk();
});

it('viewer cannot access the admin panel', function () {
    $viewer = User::factory()->create();
    $viewer->assignRole('viewer');

    $this->actingAs($viewer)
         ->get('/admin')
         ->assertForbidden();
});

it('user with direct permission can publish', function () {
    // Spatie-specific test
    $user = User::factory()->create();
    $user->assignRole('editor'); // editors can't publish
    $user->givePermissionTo('publish-posts'); // but this user specifically can

    expect($user->hasPermissionTo('publish-posts'))->toBeTrue();
    expect($user->hasDirectPermission('publish-posts'))->toBeTrue();
});

it('permission check uses cache on subsequent calls', function () {
    $user = User::factory()->create();
    $user->assignRole('admin');

    // First check — hits database
    expect($user->hasPermissionTo('edit-posts'))->toBeTrue();

    // Second check — should use cache (no additional query)
    $queryCount = 0;
    DB::listen(fn() => $queryCount++);

    expect($user->hasPermissionTo('delete-posts'))->toBeTrue();
    expect($queryCount)->toBe(0); // No additional DB queries
});

The One Scenario Where Rolling Your Own Still Makes Sense

After building both, there’s exactly one category of application where the from-scratch approach is justified: attribute-based access control (ABAC) — where permissions are evaluated against the resource’s attributes, not just the user’s roles.

The classic example: a user can edit posts if they’re the author, or if they’re an admin. The “author” check isn’t a role or a permission — it’s a relationship between the user and the specific resource.

// Spatie handles role-based and permission-based access well
// But "can this user edit this specific post?" is still a Policy concern
class PostPolicy
{
    public function update(User $user, Post $post): bool
    {
        // Role-based check via Spatie
        if ($user->hasRole('admin')) return true;

        // Attribute-based: is the user the author?
        return $post->author_id === $user->id;
    }
}

For applications where the entire permission model is attribute-based (multi-dimensional access control based on resource attributes, user attributes, and contextual conditions), Laravel Policies alone are often the right tool — and the Spatie layer isn’t adding much. The typical example: a legal document management system where access depends on the user’s department, the document’s classification level, and the user’s clearance level simultaneously.

For most SaaS products, Spatie handles the role and permission layer, and Policies handle the resource-specific logic — they compose well.


The Verdict

From scratch:
  Lines of code:          ~250 across 4 files
  Database tables:        4
  Permission caching:     Manual (you build it)
  Multi-guard support:    Manual (you build it)
  Direct user perms:      Manual (you build it)
  Polymorphic support:    No
  Maintenance burden:     Yours
  Time to production:     Half day (basic), 2+ days (complete)

Spatie:
  Lines of code:          ~30 (install + seeder)
  Database tables:        5
  Permission caching:     Automatic (Redis-backed)
  Multi-guard support:    Automatic
  Direct user perms:      Built in
  Polymorphic support:    Yes (works with any model)
  Maintenance burden:     Maintained by Spatie + community
  Time to production:     2 hours

Build the from-scratch version once to understand what you’re working with. Use Spatie after that.

The from-scratch version taught me why the guard_name column exists, why direct user permissions need their own pivot table, and why the cache invalidation on syncPermissions() is non-trivial to get right. Understanding those things made me a better user of Spatie — I know what it’s doing and why, rather than treating it as a black box.

But the 40-hour gap between “basic from-scratch” and “production-grade from-scratch with caching, multi-guard, and direct permissions” is time better spent building actual features. Spatie closes that gap in two hours, correctly, with tests, with a community that’s been running it in production for a decade.

Leave a Reply

Your email address will not be published. Required fields are marked *