Laravel roles and permissions: Spatie package, gates and policies
AdvertisementNearly every real application has more than one kind of user: visitors, members, editors, administrators. Authentication answers "who are you?" Authorisation answers "what are you allowed to do?" Laravel has built-in tools for authorisation (gates and policies), and a popular package, spatie/laravel-permission, adds database-backed roles and permissions on top. This guide shows how to set both up and how they fit together. Everything below was run on a fresh Laravel 13.38 application with PHP 8.4 and spatie/laravel-permission 8.3.2, using the default SQLite database. The same code applies to Laravel 10, 11 and 12 with the small differences noted.
The vocabulary
| Term | Meaning | Example |
|---|---|---|
| Permission | one specific action | edit articles, publish articles |
| Role | a named group of permissions | writer, admin |
| Gate | a closure that answers "may this user do X?" | Gate::define('view-reports', ...) |
| Policy | a class of authorisation rules for one model | ArticlePolicy::update() |
The cleanest design: give users roles, give roles permissions, and in your code always check permissions (not role names). Then "editors can now publish" is a data change in the database, not a code change.
Step 1: install the package
composer require spatie/laravel-permission
php artisan vendor:publish --provider="Spatie\Permission\PermissionServiceProvider"
php artisan migrate
The publish command copies config/permission.php and a migration that creates five tables: permissions, roles, model_has_permissions, model_has_roles and role_has_permissions. The last three are pivot tables that hold the links, which means a role can have many permissions, and a user can have many roles and also direct permissions.
If you run php artisan optimize in production, or use configuration caching, run php artisan config:clear after publishing the config.
Step 2: add the trait to your User model
<?php
namespace App\Models;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Spatie\Permission\Traits\HasRoles;
class User extends Authenticatable
{
use HasRoles;
// ...
}
HasRoles adds the methods assignRole, hasRole, givePermissionTo, can (extended), getRoleNames, syncRoles and more.
Step 3: create roles and permissions
Do this in a seeder, so every environment gets the same setup:
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;
app(PermissionRegistrar::class)->forgetCachedPermissions();
Permission::create(['name' => 'edit articles']);
Permission::create(['name' => 'publish articles']);
$writer = Role::create(['name' => 'writer']);
$admin = Role::create(['name' => 'admin']);
$writer->givePermissionTo('edit articles');
$admin->givePermissionTo(Permission::all());
Clearing the permission cache first matters, because the package caches permissions for speed and stale cache is the most common reason a new permission "does not work". In seeders and tests, call forgetCachedPermissions() as above; after changing permissions in normal code, the package clears the cache for you.
Step 4: give roles and permissions to users
$asha = User::factory()->create(['name' => 'Asha'])->assignRole('writer');
$ravi = User::factory()->create(['name' => 'Ravi'])->assignRole('admin');
$guest = User::factory()->create(['name' => 'Guest']);
$guest->givePermissionTo('edit articles'); // a direct permission, no role
Here is what the checks returned in my test run:
$asha->can('edit articles'); // true (through the writer role)
$asha->can('publish articles'); // false
$ravi->can('publish articles'); // true (admin has every permission)
$asha->hasRole('writer'); // true
$asha->getRoleNames(); // ['writer']
$asha->getPermissionsViaRoles(); // ['edit articles']
$guest->getDirectPermissions(); // ['edit articles']
$guest->getAllPermissions(); // roles + direct permissions combined
can() is Laravel's own authorisation method. The package plugs into it, so $user->can('edit articles') just works, and so do @can('edit articles') in Blade and Gate::allows('edit articles'). Prefer can() over hasPermissionTo() in everyday code for that reason.
To remove access later:
$asha->revokePermissionTo('edit articles'); // direct permission
$writer->revokePermissionTo('edit articles'); // from the role
$asha->syncRoles(['admin']); // replace all roles with these
$asha->removeRole('writer');
After revoking both, $asha->can('edit articles') returned false; after syncRoles(['admin']), can('publish articles') returned true.
Step 5: protect routes with middleware
The package ships three middleware classes. In Laravel 11 and newer, register aliases for them in bootstrap/app.php:
->withMiddleware(function (Middleware $middleware): void {
$middleware->alias([
'role' => \Spatie\Permission\Middleware\RoleMiddleware::class,
'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class,
'role_or_permission' => \Spatie\Permission\Middleware\RoleOrPermissionMiddleware::class,
]);
})
(In Laravel 10 and older, add the same three entries to $middlewareAliases or $routeMiddleware in app/Http/Kernel.php.) Then use them on routes:
Route::middleware(['auth', 'permission:edit articles'])
->get('/articles/edit', fn () => 'edit screen');
Route::middleware(['auth', 'role:admin'])
->get('/admin', fn () => 'admin area');
You can allow several: role:admin|editor means either role, and permission:edit articles|publish articles means either permission. Results in the test:
| Request | Result |
|---|---|
GET /articles/edit as Asha (writer) |
200 |
GET /admin as Asha (writer) |
403 |
GET /admin as Ravi (admin) |
200 |
A logged-in user without the right access gets a 403 Forbidden. An anonymous user is stopped earlier by the auth middleware and redirected to your login route. A brand-new Laravel skeleton has no login route, so you will see an error until you add authentication (Laravel Breeze, Fortify or your own login). Always put auth before the permission middleware, so the check has a user to examine.
Step 6: check in controllers and views
In a controller, use authorize or a plain check:
public function publish(Article $article)
{
$this->authorize('publish articles'); // throws a 403 if not allowed
// ...
}
if (auth()->user()->can('publish articles')) { /* ... */ }
In Blade templates:
@can('edit articles')
<a href="{{ route('articles.edit', $article) }}">Edit</a>
@endcan
@role('admin')
<a href="/admin">Admin</a>
@endrole
Hiding a button is only convenience. Always enforce the rule on the server with middleware or authorize, because anyone can craft a request by hand.
Gates: one-off rules
A gate is a closure registered in AppServiceProvider::boot(). It suits rules that aren't tied to a model:
use Illuminate\Support\Facades\Gate;
public function boot(): void
{
Gate::define('view-reports', fn ($user) => $user->hasPermissionTo('view reports'));
}
$analyst->can('view-reports'); // true (has the 'view reports' permission)
$other->can('view-reports'); // false
A super-admin that passes every check
Gate::before runs before all other checks. Returning true grants access; returning null lets the normal rules decide (returning false would deny everything, so don't):
Gate::before(function ($user, $ability) {
return $user->hasRole('super-admin') ? true : null;
});
In the test, a super-admin could update an article they did not own, while ordinary users could not. Use this sparingly: it bypasses every rule.
Policies: rules about a specific model
Permissions say what kind of action a user may perform. Often you also need which record: "writers can edit articles, but only their own." That is what policies are for. Generate one with php artisan make:policy ArticlePolicy --model=Article, then write the rules:
class ArticlePolicy
{
public function update(User $user, Article $article): bool
{
return $user->id === $article->user_id;
}
public function delete(User $user, Article $article): bool
{
return $user->id === $article->user_id || $user->hasRole('admin');
}
}
Laravel discovers App\Policies\ArticlePolicy for App\Models\Article automatically. Use it anywhere:
$user->can('update', $article); // in code
$this->authorize('update', $article); // in a controller
Route::put('/articles/{article}', fn (Article $article) => 'updated')
->middleware(['auth', 'can:update,article']);
Results from the test: the owner could update their article (200), another user got 403, an admin could delete someone else's article, and a non-owner non-admin could not.
The strongest approach combines both: a permission for the action type (edit articles in middleware), plus a policy for ownership of the record.
Wildcards, guards and teams (briefly)
- Guards: roles and permissions belong to a guard (
webby default). If you use an API guard (Sanctum or Passport), create roles with the matchingguard_name, or the checks will report "role does not exist". - Multi-tenant apps: the package has a "teams" feature that scopes roles per team; run
php artisan permission:setup-teams(it sets the config flag and generates the extra migration) before running the migrations. - Wildcard permissions such as
articles.*are available if you enable them in the config.
Rolling your own instead
For a tiny app, you may not need a package. The minimum schema is a roles table, a permissions table, a role_user pivot and a permission_role pivot, with foreign keys and a composite primary key on each pivot. You then write hasRole, hasPermission and a Gate::before or Gate::define that consult them, and cache the results yourself. That is a day of work that the package has already done and tested, along with the caching and the middleware. Roll your own only if your rules truly do not fit the package model.
Common problems
| Symptom | Likely cause |
|---|---|
| A new permission "does not work" | stale cache; call forgetCachedPermissions() or php artisan permission:cache-reset |
| "There is no role named X for guard Y" | the role was created for a different guard |
Call to undefined method assignRole() |
the HasRoles trait is missing on the model being checked |
| Middleware error "Target class [role] does not exist" | the middleware alias is not registered |
| Everyone gets 403 | the seeder did not run, or the user has no role; check with getRoleNames() |
Gate::before grants too much |
it returned false or true where null was intended |
Security checklist
- Enforce on the server, every time. Hiding menu items is not security.
- Check permissions, not role names, in application code.
- Give new users the least access, and grant more deliberately.
- Protect the "assign roles" screen itself with a permission, so nobody can promote themselves.
- Write tests for your most important rules, as shown below.
public function test_writer_cannot_open_admin(): void
{
$user = User::factory()->create()->assignRole('writer');
$this->actingAs($user)->get('/admin')->assertForbidden();
}
Next steps
Authorisation sits on top of a solid backend. If you are learning PHP from the beginning, start with the free PHP course and its overview post, practise questions in the PHP certificate exam guide, and review database design in normalisation, because the roles and permissions tables above are a many-to-many design. The roles-and-permissions schema also benefits from the indexes the package adds.