mateusjatenee/nova-permission
Nova Permission adds a tool to Laravel Nova for managing roles and permissions. Create roles, attach permissions, and assign roles to users from a dedicated Nova interface, keeping access control editable without custom code or migrations.
Check the version gate before anything else. v4.0.0 requires PHP 8.1+ and spatie/laravel-permission ^5.0. These two constraints are the whole release, and they move together — running the package on laravel-permission 4, or on PHP 8.0, is the single most common way this upgrade breaks.
php -v # must be 8.1.x or newer
composer show spatie/laravel-permission # must resolve to 5.x after upgrade
Upgrade as one operation, not two:
composer require "vendor/package:^4.0" "spatie/laravel-permission:^5.0"
php artisan vendor:publish --tag=package-config --force
php artisan migrate
php artisan permission:cache-reset
Look first at the published package config and config/permission.php. The laravel-permission 5 config has a different shape than 4 (notably around cache and guards), so a stale published config is a silent source of wrong behaviour. Guards and cache settings there drive almost everything you will debug later.
First use case — a 10-minute smoke test that exercises the whole stack (tables → model → Gate → middleware):
$user = User::first();
$user->assignRole('admin');
$user->givePermissionTo('posts.publish');
$user->hasPermissionTo('posts.publish'); // true (direct)
$user->can('posts.publish'); // true (through Gate)
Then protect one route with the package's middleware and hit it as a permitted and a non-permitted user. If the permitted user gets a 403, you have a guard-name mismatch or a stale permission cache — not a package bug. See Gotchas.
Treat the permission catalogue as data, not code. Define every permission in an idempotent seeder and re-run it on every deploy. This is what makes v4's laravel-permission 5 dependency a benefit rather than a chore — the seeder is the only place that needs to know about permission names, and it stays stable across the upgrade.
// database/seeders/PermissionSeeder.php
foreach (['posts.view', 'posts.publish', 'users.manage'] as $name) {
Permission::findOrCreate($name, 'web'); // idempotent, guard-explicit
}
$admin = Role::findOrCreate('admin', 'web');
$admin->syncPermissions(['posts.view', 'posts.publish', 'users.manage']);
Always pass the guard explicitly. findOrCreate($name) without a guard works only while you have a single guard configured, and quietly becomes ambiguous the moment a second guard appears.
Register the middleware aliases once, centrally. laravel-permission 5 ships middleware under a renamed namespace; alias them in your HTTP kernel (or bootstrap/app.php on Laravel 11+) and reference only the alias everywhere else:
// Laravel 10 and below: app/Http/Kernel.php
protected $routeMiddleware = [
'role' => \Spatie\Permission\Middleware\RoleMiddleware::class,
'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class,
'role_or_permission' => \Spatie\Permission\Middleware\RoleOrPermissionMiddleware::class,
];
Then routes stay readable and the namespace detail lives in one file:
Route::post('/posts/{post}/publish', PublishPost::class)
->middleware('permission:posts.publish');
Pair coarse middleware with fine-grained policies. Middleware answers "can this user touch this endpoint"; policies answer "can this user touch this record". Don't try to encode ownership in permission names.
class PostPolicy
{
public function update(User $user, Post $post): bool
{
return $user->can('posts.publish') && $post->author_id === $user->id;
}
}
Super-admin via Gate::before, not via a giant role. Give the admin role every permission and you will re-seed on every feature. One Gate::before in a service provider is simpler and cheap:
Gate::before(fn (User $user) => $user->hasRole('super-admin') ? true : null);
Return null, not false, or you short-circuit every other gate check.
Make cache invalidation part of the deploy script. In a deploy pipeline this is not optional:
php artisan migrate --force
php artisan db:seed --class=PermissionSeeder --force
php artisan permission:cache-reset
php artisan queue:restart # long-lived workers hold stale registrars
In tests, reset the registrar in setUp(). Permissions are cached process-wide, and Laravel's test suite reuses the process across tests:
protected function setUp(): void
{
parent::setUp();
app(\Spatie\Permission\PermissionRegistrar::class)->forgetCachedPermissions();
}
Seed the permission catalogue in your base test case, then use actingAs($user) — assertions on can() are then fast and deterministic.
Multi-guard and teams. If you use teams, set teams => true and always resolve permissions in the context of a team (setPermissionsTeamId()), including inside queued jobs. If you use multiple guards, keep permission names per-guard and never share a role across guards; the package stores the guard name on every role and permission row.
The middleware namespace changed in laravel-permission 5. Spatie\Permission\Middlewares\* became Spatie\Permission\Middleware\* (plural → singular). Any hard-coded FQCN in a kernel, route file, or cached route config will fail with a "class not found" that only shows up when the route is actually hit. Grep for Middlewares\\ after upgrading.
hasPermissionTo() throws; can() returns false. A missing permission raises PermissionDoesNotExist rather than returning false. Code that guards with if ($user->hasPermissionTo($name)) will explode the first time someone deletes a permission row. Prefer $user->can($name) in conditionals, or wrap in a try/catch at the boundary.
Guard mismatch is the number-one 403. The user's guard (from the auth provider) must match the guard the permission was created under. Symptom: the permission clearly exists in the DB, hasPermissionTo says no. Check config/auth.php providers and the guard argument in your seeder before you check anything else.
findByName() needs a guard when more than one guard exists. With a single guard it is optional; add a second guard and every guard-less lookup starts resolving against the default guard. Passing it explicitly from day one costs nothing and prevents a very confusing regression later.
Stale permission cache survives config:cache. php artisans optimize:clear does not clear the permission cache. It has its own store and its own command (permission:cache-reset). If a deploy "didn't take" for permissions only, this is why. Watch for this especially with CACHE_DRIVER=file across multiple app servers — each node has its own copy.
Don't republish migrations over an existing install. vendor:publish --force on migrations will overwrite files you may have edited and will not alter existing tables. On an upgrade, diff the new migration stubs against what you have and write an explicit migration instead.
Re-publish the config, then read the diff. laravel-permission 5 added/renamed config keys. --force gives you the new file but discards your customisation, so publish to a scratch tag, diff, and merge by hand if you have non-default cache or teams settings.
Model swapping is a config contract, not a suggestion. If config/permission.php points at your own App\Models\Role, that class must extend the package's Role and implement the same contract. After a major bump, re-check that your subclasses still satisfy the parent's method signatures (PHP 8.1 will be loud about this at boot, which is the good outcome).
CI and container images need an explicit bump. Add "platform": {"php": "8.1.0"} in composer.json (or bump your base image) so that a developer on 8.0 gets a clear resolution error locally rather than a green build and a broken deploy.
Wildcard permissions are opt-in. posts.* does nothing unless enable_wildcard_permission is true in config, and it is checked at a different point in the Gate pipeline than exact names. If a wildcard "worked" in a previous version, verify it still does after the upgrade rather than assuming.
Long-running processes are the quiet failure mode. Octane workers, queue consumers, and Horizon supervisors keep the registrar in memory across requests. Any deploy that changes permissions must queue:restart (and reload Octane) or you will spend an afternoon debugging a fix that is already correct in the database.
Extension points worth knowing before you need them: a custom PermissionRegistrar binding for multi-tenant cache keys, a Gate::before for platform admins, and event listeners on the package's role/permission attach events for audit logging. The last one is much easier to add in v4 (on PHP 8.1) than to retrofit.
How can I help you explore Laravel packages today?