sereny/nova-permissions
A Laravel Nova tool that adds a user-friendly interface for managing roles and permissions. Create roles, define permissions, and assign them to users directly from your Nova admin panel, with seamless integration with spatie/laravel-permission and no code required.
Verify four preconditions before installing. This package is a Nova presentation layer over spatie/laravel-permission; it is inert without all four:
spatie/laravel-permission installed, config published, migrations run.App\Models\User) uses the HasRoles trait.Install and pin immediately.
composer require sereny/nova-permissions
php artisan vendor:publish --tag=nova-permission-config
Pin the exact versions of all three moving parts — this package, Nova, and Spatie — in composer.json. Nova majors rewrite internals; a floating constraint on any of the three is how you get a broken admin screen on a Tuesday.
"sereny/nova-permissions": "1.2.3",
"spatie/laravel-permission": "6.9.0"
Register the tool and resources in app/Providers/NovaServiceProvider.php:
use Sereny\NovaPermissions\NovaPermission;
use Sereny\NovaPermissions\Nova\Role;
use Sereny\NovaPermissions\Nova\Permission;
public function tools()
{
return [NovaPermission::make()];
}
public function resources()
{
Nova::resources([Role::class, Permission::class]);
}
Confirm the exact vendor namespace and class paths against the autoload block in vendor/sereny/nova-permissions/composer.json — small Nova packages rename resources between releases more often than you'd like.
Look here first: config/nova-permission.php. It is the switchboard for which resource classes are used and how permissions are grouped in the matrix. Read it before customising anything.
First use case — the 20-minute win. Expose the tool to super-admins only (gate below), then walk an ops lead through: create a billing-manager role → tick invoice.view, invoice.download, refund.approve in the grouped permission matrix → assign the role from the user's detail page. That is a sprint of bespoke Nova resource work you did not write.
Gate the tool server-side before anything else. Hiding it from the navigation menu is cosmetic. Enforce reachability:
use Illuminate\Support\Facades\Gate;
protected function gate()
{
Gate::define('viewNovaPermission', fn ($user) => $user->hasRole('super-admin'));
}
Then assert it: a non-admin hitting the tool route must get 403. Without this, any user who reaches the screen can grant themselves permissions — that is a privilege-escalation path, not a UI nicety.
Never import a Sereny\ class from application or policy code. This is the entire hedge. Application authorization binds to Spatie's contract — HasRoles, Gate, policies, can: middleware — so if this package stalls on a future Nova major, production authorization keeps working and you lose only an admin screen.
// Good — survives the package being removed tomorrow
Gate::define('approve-refund', fn ($user) => $user->hasPermissionTo('refund.approve'));
// Bad — couples your authz to a UI package
use Sereny\NovaPermissions\...;
Enforce this in code review; it converts an 87-star dependency into a replaceable-UI decision.
Seed canonical roles and permissions; treat the UI as convenience only.
// database/seeders/PermissionSeeder.php
public function run(): void
{
foreach (['invoice.view', 'invoice.download', 'refund.approve'] as $name) {
Permission::firstOrCreate(['name' => $name, 'guard_name' => 'web']);
}
$role = Role::firstOrCreate(['name' => 'billing-manager', 'guard_name' => 'web']);
$role->syncPermissions(['invoice.view', 'invoice.download']);
}
New environments get reproducible state. The UI exists so ops can assign users, not so it becomes the source of truth for the taxonomy.
Lock the naming convention before exposing the UI. Grouped permission matrices make it trivially easy for someone to create view invoices, Invoices-View, and invoice_view in one afternoon. Publish a resource.action convention, own it with a named person, and review new permissions like a migration.
Use the Role resource as the primary workflow surface. Attach permissions to roles via the grouped checkbox field (far faster than one at a time from the Permission resource), then assign roles to users. Direct user→permission grants bypass the role model and produce an access graph nobody can reason about six months later. Reserve them for exceptions, if at all.
Flush the permission cache on code paths that bypass the UI. Spatie invalidates on model events, so Nova-driven saves are fine — but seeders, artisan commands, and query-builder writes are not, and a stale cache looks exactly like a broken permission.
app(\Spatie\Permission\PermissionRegistrar::class)->forgetCachedPermissions();
Test authorization flows, not the UI.
public function test_billing_manager_can_approve_refund(): void
{
$user = User::factory()->create();
$user->assignRole('billing-manager');
$this->actingAs($user)
->get(route('refunds.approve', $refund))
->assertOk();
}
Run it against both a seeder-populated database and one mutated through the UI — it catches the "somebody renamed a permission in Nova and broke a policy" class of incident.
Document a manual fallback. When (not if) the tool breaks on a Nova upgrade, someone needs to grant a role without the UI:
php artisan tinker
>>> User::where('email','[email protected]')->first()->assignRole('billing-manager');
Put it somewhere a non-engineer can find it.
Nova majors are the entire risk surface. Nova 4 was a Vue 3/Inertia rewrite; Nova 5 moved again. Third-party Nova tools historically lag majors by weeks to quarters. Budget a spike at every Nova upgrade, check the package's declared Nova constraint in its own composer manifest, and run the upgrade on a branch against your authorization test suite — not a manual click-through. The 2024-12-27 release indicates active tracking, but there is no published compatibility guarantee beyond composer constraints.
"0 dependents" is a measurement artifact. Packagist cannot crawl dependents of a package requiring the closed-source laravel/nova, so the dependents graph is structurally blind. Read it as unknown, not as no adoption. 87 stars is small-but-real niche adoption; plan for bus-factor-of-one.
Guard mismatch produces silent "permission denied." Every permission and role row carries a guard_name. A web-guarded role assigned to an api-guarded actor fails quietly — no error, just a false from can(). Verify early:
>>> $user->getAllPermissions()->pluck('guard_name')->unique();
If that returns an unexpected guard, stop and fix the config before building on top of it.
Teams-enabled changes the semantics of the whole screen. With 'teams' => true in config/permission.php, roles and permissions resolve per team, and the stock UI will not model team assignment meaningfully. Decide before installation, never after — retrofitting leaves every role created in the UI with ambiguous scope.
Cache store must be shared across nodes. Spatie caches permission lookups. With per-node file caches on a load-balanced fleet, a role change in Nova updates one node's cache and diverges authorization fleet-wide — the nastiest possible bug, because it is intermittent and node-dependent. Use Redis. Manual nuke: php artisan permission:cache-reset.
No built-in audit trail. Expect no change history for role or permission mutations. If "who changed whose access, when" is a compliance requirement, scope it now as a blocker, not a nice-to-have later. Supplement with a permission event listener or a model observer on Role/Permission writing to an append-only log table, logging Auth::id() — the mutation may originate from the Nova screen or a seeder.
Deleting a role is live-fire. The UI will happily let an admin delete a role four people depend on, and Spatie's cascade drops the model_has_roles rows. Confirm the delete confirmation copy in staging, and consider a Spatie policy blocking deletion of any role in your canonical seeder list.
Bulk edits through the query builder skip model events and therefore skip cache invalidation. If you ever write DB::table('role_has_permissions')->insert(...), call forgetCachedPermissions() yourself or ship a mystery.
Pin, and write down the exit. The exit is genuinely cheap because state lives in Spatie's tables: swapping to vyuldashev/nova-permission, a hand-rolled screen, or nothing at all is a composer change plus re-registering a tool — role and permission data is untouched. Commit the pin, note the swap path in your decision record, and re-evaluate at each Nova major. The only thing that makes the exit expensive is letting application code import package classes, so guard that boundary like a policy.
How can I help you explore Laravel packages today?