The standard way to handle Laravel roles and permissions in 2026 is the spatie/laravel-permission package. It stores roles and permissions in database tables, attaches them to your User model with one trait, and registers every permission with Laravel's native Gate, so @can, Gate::authorize(), and route middleware keep working the way you already expect.
Most teams get the install right and the data model wrong. You have probably shipped an is_admin boolean, then added a role column, then scattered if ($user->role === 'manager') across half a dozen controllers. That holds up fine until somebody asks for a manager who can refund orders but not delete them.
This guide walks through the setup, the code you will actually write, and the design decisions that decide whether your access control survives the next three feature requests. By the end you will have a working Laravel RBAC layer, the middleware and Blade patterns that enforce it, the caching and tenancy details that bite six months in, and a short list of mistakes worth skipping.
Key Takeaways
What Laravel roles and permissions actually mean
Laravel roles and permissions are two different things that people constantly collapse into one. A permission is a single verb, something a user is allowed to do, like refund orders or manage users. A role is a named bundle of those verbs, like support agent or editor.
That split is the whole idea behind role based access control, shortened to Laravel RBAC in most conversations. Users get roles. Roles carry permissions. Your application asks about permissions and stays quiet about role names.
Why checking role names breaks later
Compare these two checks. They look equivalent on the day you write them.
// Fragile. The rule now depends on an org chart that will change.
if ($user->hasRole('manager')) {
$order->refund();
}
// Durable. The rule depends on the capability itself.
if ($user->can('refund orders')) {
$order->refund();
}The second version survives a rename, a new tier, and the inevitable request for one contractor who needs a single extra ability. You change data, not code. That one habit prevents most of the rework teams do around Laravel user roles in year two.
Prefer to skip the boilerplate entirely? You can describe the access model in plain English and get started free with StackMivo, which returns production-ready Laravel apps you own and can edit.
Build your Laravel app with AI
Skip the boilerplate. StackMivo turns a plain-English prompt into production-ready Laravel apps, with models, migrations, controllers, CRUD, auth, and tests.
Get started freeSetting up spatie/laravel-permission
Install the package, publish its migrations and config, then run them. The package supports Laravel 9 through 12, so the commands below are the same across current projects.
composer require spatie/laravel-permission
php artisan vendor:publish --provider="Spatie\Permission\PermissionServiceProvider"
php artisan migrateYou get five tables. Two hold the definitions (roles and permissions), and three hold the relationships (model_has_roles, model_has_permissions, and role_has_permissions). Nothing is stored on your users table, which is exactly why the model stays flexible.
Next, add the trait to any model that needs access rules. Usually that is User, but it can also be an API client or a service account model.
namespace App\Models;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Spatie\Permission\Traits\HasRoles;
class User extends Authenticatable
{
use HasRoles;
}On Laravel 11 and 12 you also register the middleware aliases yourself, because the old HTTP kernel is gone. This step is quietly responsible for a lot of "class not found" confusion during upgrades.
// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
$middleware->alias([
'role' => \Spatie\Permission\Middleware\RoleMiddleware::class,
'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class,
'role_or_permission' => \Spatie\Permission\Middleware\RoleOrPermissionMiddleware::class,
]);
})Before you write anything else, open config/permission.php and decide on two things. First, whether you need the teams feature. Second, whether your app uses more than one guard, for example web for the dashboard and api for a mobile client. Both settings are far cheaper to choose now than to migrate later. The official spatie/laravel-permission documentation covers every config key in detail.
Assigning Laravel roles and permissions in code
Define permissions once in a seeder so every environment gets the same set. Name them as verb plus noun, keep them lowercase, and resist the urge to encode the role into the permission name.
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;
app(PermissionRegistrar::class)->forgetCachedPermissions();
foreach (['view orders', 'refund orders', 'delete orders', 'manage users'] as $name) {
Permission::findOrCreate($name);
}
$agent = Role::findOrCreate('support agent');
$agent->givePermissionTo(['view orders', 'refund orders']);
$admin = Role::findOrCreate('admin');
$admin->givePermissionTo(Permission::all());Using findOrCreate instead of create makes the seeder safe to re-run, which matters the first time a teammate adds one permission to the list and reseeds a shared staging database.
Day to day, the API you touch is short. These are the methods that cover roughly every case you will hit in a normal app.
$user->assignRole('support agent');
$user->syncRoles(['support agent', 'editor']);
$user->givePermissionTo('delete orders'); // direct, skips roles entirely
$user->removeRole('editor');
$user->can('refund orders'); // true, inherited from the role
$user->hasRole('admin'); // fine for UI, avoid in business logic
$user->getAllPermissions(); // roles plus direct permissionsDirect permissions are the escape hatch for one-off exceptions. They are useful, and they are also the thing that makes an access model hard to audit, so treat every direct grant as a small debt. If you are generating the models and factories that back all of this, the php artisan make:model cheatsheet covers the flags that save the most typing.
Enforcing permissions in routes, controllers, and Blade
A permission that is never checked is a comment. Enforcement belongs in three layers, and each one answers a different question.
Layer | Answers | Use it for |
|---|---|---|
Route middleware | Can this kind of user reach this endpoint | Whole sections, admin groups, coarse gates |
Policy | Can this user act on this specific record | Ownership, tenancy, record state |
Blade directive | Should this user see this control | Buttons, nav items, dashboard widgets |
Start with middleware. It is the cheapest check and it fails before your controller ever runs.
Route::post('/orders/{order}/refund', RefundController::class)
->middleware('permission:refund orders');
Route::middleware('role:admin')->prefix('admin')->group(function () {
Route::resource('users', UserController::class);
});Inside a controller, use Gate::authorize(). On Laravel 11 and 12 the base controller no longer pulls in the AuthorizesRequests trait, so $this->authorize() only works if you add it back yourself. The Gate facade works everywhere.
public function refund(Order $order)
{
Gate::authorize('refund orders');
$order->refund();
return back()->with('status', 'Order refunded');
}Permissions handle the category of action. Ownership and tenancy belong in a Policy, because only a Policy sees the record. Laravel's authorization documentation covers registration and discovery, and the two systems compose cleanly.
class OrderPolicy
{
public function refund(User $user, Order $order): bool
{
return $user->can('refund orders')
&& $user->team_id === $order->team_id;
}
}Blade comes last, and only for presentation.
@can('refund orders')
<button type="submit">Refund order</button>
@endcan
@role('admin')
<a href="{{ route('admin.users') }}">User management</a>
@endroleA small agency team once shipped an internal dashboard where the delete button sat inside an @can block and the route itself carried no middleware. Every permission looked correct in the UI. A curious contractor copied a URL from a colleague's screen share, hit the endpoint directly, and removed a record nobody expected them to reach. The fix took four minutes. The incident review took two days. Blade hides things. It does not protect them.
Laravel RBAC patterns that scale past launch
The install is the easy part. These four decisions are what separate a permission layer you extend from one you rewrite.
Give super admins a Gate bypass
Do not grant a super admin every permission row. Let them short circuit the Gate instead, so new permissions are covered automatically the day they are created.
// AppServiceProvider::boot()
Gate::before(function ($user, $ability) {
return $user->hasRole('super admin') ? true : null;
});Return null rather than false for everyone else. Returning false stops the rest of the authorization chain and quietly disables every Policy in the app.
Decide tenancy before the first migration
If your app is multi-tenant, enable teams in config/permission.php before you run the package migrations. The teams feature adds a tenant column to the pivot tables and scopes lookups with setPermissionsTeamId(), usually inside a middleware that reads the current tenant. Turning it on afterwards means a data migration across every pivot table. The package repository documents the exact ordering. For a broader look at how tenancy shapes the rest of the schema, our guide on building multi-tenant SaaS applications covers the surrounding pieces.
Reset the permission cache after every deploy
The package caches its permission map for 24 hours. That is great for request performance and terrible for debugging, because a permission you just seeded will not exist as far as the Gate is concerned. Add this to your deploy script and to any seeder.
php artisan permission:cache-resetTest the rules, not the roles
Write tests that assert what a role cannot do. Negative tests are the ones that catch a regression when somebody widens a permission to unblock themselves.
it('stops support agents from deleting orders', function () {
$order = Order::factory()->create();
$agent = User::factory()->create();
$agent->assignRole('support agent');
actingAs($agent)
->delete("/orders/{$order->id}")
->assertForbidden();
});If your suite uses RefreshDatabase, remember to run the permission seeder in your test setup. A missing role in the test database produces a failure that looks like an authorization bug and is not one.
Mistakes that cost teams a refactor
Checking role names in business logic. Check permissions. Roles are a grouping convenience, not a rule.
Creating a permission per record. A permission named
edit order 4821means you needed a Policy.Mixing guards. A permission created under
webis invisible to anapirequest. Setguard_namedeliberately.Skipping the cache reset. Most "the permission does not work" reports end here.
Hiding UI without guarding the route. Every
@canblock needs a matching server side check.
Generating Laravel roles and permissions with AI
Everything above is well understood, repetitive, and easy to get subtly wrong. That combination is exactly where an AI assistant earns its place, with one condition. It has to know Laravel.
Generic assistants trained mostly on JavaScript tend to produce Laravel-shaped PHP. You get a working migration and a controller that ignores FormRequests, an authorization check that never touches the Gate, and no tests. The output compiles and the conventions are wrong, which is the most expensive kind of wrong because it passes review.
StackMivo takes the opposite approach. Its Laravel-native intelligence is built around the actual ecosystem, so a prompt like "add role based access control with support agent, editor, and admin roles, scoped to teams" returns the migration, the seeder, the middleware registration, the Policies, the Blade guards, and Pest tests as standard Laravel you can read and edit. Connect a GitHub, GitLab, or Bitbucket repository and its AI code generation indexes your existing models and routes first, so the generated permission layer matches the naming you already use.
A solo developer building an internal approvals tool described the access model in a sentence, reviewed the generated Policies against the team's real approval chain, corrected one rule about who can approve their own request, and shipped the same afternoon. The value was not that the AI was flawless. It was that the reviewable draft arrived in minutes instead of a day, and the tests came with it. The same pattern applies whether you are building internal tools or a customer facing product with a real permission matrix, and it is how our walkthrough on building a CRM with Laravel handles the same problem at a larger scale.
You still own the review. Read the Policies, run the negative tests, and confirm the guard names. AI shortens the typing, not the thinking.
Where to go from here
Getting Laravel roles and permissions right comes down to a handful of choices you make early. Install spatie/laravel-permission, keep permissions granular and roles thin, check capabilities rather than role names, and enforce every rule on the server before you hide anything in Blade. Put ownership and tenancy in Policies where the record is actually visible. Reset the permission cache on deploy, and write the tests that prove a support agent cannot do an admin's job.
Start small. Define four or five permissions for the feature in front of you, wire the middleware, add two negative tests, and grow the matrix as real requirements arrive. A permission model built that way absorbs new roles without touching controllers, which is the entire point of Laravel RBAC.
The teams that stay fast are not the ones with the cleverest authorization layer. They are the ones whose access rules are boring, tested, and easy to explain to the next developer who joins.
Build your Laravel app with AI
Skip the boilerplate. StackMivo turns a plain-English prompt into production-ready Laravel apps, with models, migrations, controllers, CRUD, auth, and tests.
Get started free