How-To: Add a Permission and Grant it to Roles
CPR uses Spatie Laravel Permission. Permissions are declared in code (not the DB UI) so they're versioned with the migration.
No admin bypass
There is no Gate::before admin override in this codebase. The admin role gets every permission only because it's assigned every key in RoleSeeder. If you add a new permission and forget to grant it to admin, even admin users will be blocked.
1. Declare the permission in config
// config/permissions.php
return [
'permissions' => [
// ...
'reservations.access',
'reservations.create',
'reservations.update',
'reservations.cancel',
'patient-duplicates.access',
'data-mappings.access',
'import-history.access',
],
];CPR convention: domain.verb, lowercase, dot-separated. .access is the read/list permission; verb-specific keys gate writes.
2. Sync permissions to the database
php artisan cpr:permissions:syncsync only creates, never grants
cpr:permissions:sync only inserts new permissions into the permissions table. It does not assign them to roles. You still have to do step 3.
3. Grant the permission to roles
Edit database/seeders/RoleSeeder.php — every role's permission set is declared here. Re-running the seeder is idempotent.
// database/seeders/RoleSeeder.php
$admin->syncPermissions(Permission::pluck('name')); // gets everything new automatically
$cashier->givePermissionTo([
'reservations.access',
'reservations.create',
'reservations.cancel',
]);Then run:
php artisan db:seed --class=RoleSeederFor a one-off grant in production (e.g., adding a new permission to an existing role without re-seeding everything):
php artisan tinker
> $role = Spatie\Permission\Models\Role::findByName('Cashier');
> $role->givePermissionTo('reservations.cancel');4. Enforce the permission on routes
Inertia / admin routes
// routes/web.php
Route::middleware('permission:reservations.access')->group(function () {
Route::get('reservations', [ReservationController::class, 'index']);
});API routes
// routes/api/v1/queue.php
Route::middleware('permission:reservations.create')->group(function () {
Route::post('reservations', [ReservationController::class, 'store']);
});For per-action checks in a controller:
public function update(Request $request, Reservation $reservation)
{
abort_unless(
$request->user()->can('reservations.update'),
403
);
// ...
}5. Check from the frontend
<!-- Inertia (Vue 3) -->
<button v-if="$can('reservations.cancel')" @click="cancel">Cancel</button>
<!-- Nuxt (cpr-frontend) -->
<button v-if="useCanAccess('reservations.cancel')" @click="cancel">Cancel</button>$can() reads from the auth.permissions Inertia prop and is set up in resources/js/plugins/permissions.ts.
6. Test the permission gate
it('forbids users without reservations.cancel', function () {
$user = User::factory()->create();
Sanctum::actingAs($user);
$reservation = QueueReservation::factory()->create();
$this->putJson("/api/v1/queue/reservations/{$reservation->id}/cancel")
->assertForbidden();
});
it('allows users with the permission', function () {
$user = User::factory()->create();
$user->givePermissionTo('reservations.cancel');
Sanctum::actingAs($user);
// ...
});Checklist
- [ ] Permission name follows
domain.verb - [ ] Added to
config/permissions.php - [ ]
php artisan cpr:permissions:syncrun - [ ] Granted to roles in
RoleSeeder(not just admin) - [ ] Route protected with
permission:middleware - [ ] Frontend UI guarded with
$can()/useCanAccess() - [ ] Test asserts 403 without permission and 200 with it
