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.
