Business apps rarely have one type of user. A cashier should only record sales, a manager should see reports, and an admin should be able to delete records. If those differences are written as if ($user->role == 'admin') all over the codebase, every rule change means hunting through dozens of files, and something always gets missed.
This tutorial builds a clean access system in Laravel 11: authentication with Laravel Breeze, then roles and permissions with spatie/laravel-permission (v6). We start with access design, then the code, then the errors that tend to surface in production.
1. Authentication vs Authorization
- Authentication answers "who are you?": login, logout, password reset, email verification.
- Authorization answers "what may you do?": roles, permissions and policies.
Spatie handles authorization on top of Laravel's built-in Gate, so $user->can() and @can keep working as usual.
2. Authentication with Breeze
composer require laravel/breeze --dev
php artisan breeze:install blade
php artisan migrate
npm install && npm run build
You now have login, registration, password reset and profile pages, plus the auth middleware. For internal tools such as a POS or HR system, consider disabling public registration and creating accounts from an admin screen only.
3. Design Access Before Writing Code
Write down the actions, then decide which role may perform each one. An example for a small point-of-sale app:
| Permission | Cashier | Manager | Admin |
|---|---|---|---|
| sales.create | Yes | Yes | Yes |
| sales.void | No | Yes | Yes |
| reports.view | No | Yes | Yes |
| products.manage | No | Yes | Yes |
| users.manage | No | No | Yes |
An object.action naming pattern keeps the list readable and sortable. Whatever you choose, be consistent.
4. Installing spatie/laravel-permission
composer require spatie/laravel-permission
php artisan vendor:publish --provider="Spatie\Permission\PermissionServiceProvider"
php artisan optimize:clear
php artisan migrate
This creates five tables: roles, permissions, model_has_roles, model_has_permissions and role_has_permissions. Add the trait to your User model:
use Spatie\Permission\Traits\HasRoles;
class User extends Authenticatable
{
use HasRoles;
}
5. A Seeder as the Single Source of Truth
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;
class RolePermissionSeeder extends Seeder
{
public function run(): void
{
app(PermissionRegistrar::class)->forgetCachedPermissions();
$matrix = [
'cashier' => ['sales.create'],
'manager' => ['sales.create', 'sales.void', 'reports.view', 'products.manage'],
];
$all = collect($matrix)->flatten()->push('users.manage')->unique();
foreach ($all as $name) {
Permission::firstOrCreate(['name' => $name, 'guard_name' => 'web']);
}
foreach ($matrix as $role => $perms) {
Role::firstOrCreate(['name' => $role, 'guard_name' => 'web'])
->syncPermissions($perms);
}
Role::firstOrCreate(['name' => 'admin', 'guard_name' => 'web']);
}
}
syncPermissions() replaces a role's permissions with exactly the array you pass, so removing a permission from the matrix and re-running the seeder revokes it. The admin role gets nothing here on purpose; a Gate rule covers it.
6. Super-Admin via Gate::before
use Illuminate\Support\Facades\Gate;
public function boot(): void
{
Gate::before(fn ($user, $ability) => $user->hasRole('admin') ? true : null);
}
Return null for everyone else, not false. Returning false would deny every check for non-admins.
7. Protecting Routes
->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,
]);
})
Route::middleware('auth')->group(function () {
Route::post('/sales', [SaleController::class, 'store'])
->middleware('permission:sales.create');
Route::get('/reports', [ReportController::class, 'index'])
->middleware('permission:reports.view');
});
The role: middleware compares role names directly and ignores Gate::before, while permission: goes through the Gate, so admins still pass. One more reason to check permissions rather than roles.
8. Checks in Controllers and Blade
use Illuminate\Support\Facades\Gate;
public function destroy(Sale $sale)
{
Gate::authorize('sales.void');
$sale->update(['status' => 'void']);
return back();
}
The Laravel 11 base controller no longer includes AuthorizesRequests, so $this->authorize() fails unless you add the trait back. Gate::authorize() works everywhere.
@can('reports.view')
<a href="{{ route('reports.index') }}">Reports</a>
@endcan
Hiding a button is cosmetic. Real protection belongs on the route or in the controller, because anyone can type a URL.
9. Permissions Plus Policies
A permission says whether someone may void sales at all. It cannot say whether they may void a sale from another branch. Combine it with a Policy:
public function delete(User $user, Sale $sale): bool
{
return $user->can('sales.void') && $sale->branch_id === $user->branch_id;
}
10. Common Errors
- "There is no permission named X for guard web": a typo, or the seeder has not run.
- Changes do not take effect: cached permissions. Run
php artisan permission:cache-reset. - Unknown middleware: alias missing, or an old namespace (
Middlewaresin v5,Middlewarein v6). - API requests always get 403: permissions exist for the
webguard but the request uses another guard.
Implementation Checklist
- A written access matrix approved by the business owner.
- All roles and permissions created by a seeder.
- Code checks permissions, not role names.
- Every sensitive route has middleware, not just a hidden button.
- A test confirms that a cashier gets 403 on the reports page.
For related hardening, read 10 must-have web security practices, and for per-client roles see building a multi-tenant SaaS with Laravel.