Skip to content

Last updated:

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

php
// 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

bash
php artisan cpr:permissions:sync

sync 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.

php
// database/seeders/RoleSeeder.php
$admin->syncPermissions(Permission::pluck('name')); // gets everything new automatically

$cashier->givePermissionTo([
    'reservations.access',
    'reservations.create',
    'reservations.cancel',
]);

Then run:

bash
php artisan db:seed --class=RoleSeeder

For a one-off grant in production (e.g., adding a new permission to an existing role without re-seeding everything):

bash
php artisan tinker
> $role = Spatie\Permission\Models\Role::findByName('Cashier');
> $role->givePermissionTo('reservations.cancel');

4. Enforce the permission on routes

Inertia / admin routes

php
// routes/web.php
Route::middleware('permission:reservations.access')->group(function () {
    Route::get('reservations', [ReservationController::class, 'index']);
});

API routes

php
// 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:

php
public function update(Request $request, Reservation $reservation)
{
    abort_unless(
        $request->user()->can('reservations.update'),
        403
    );
    // ...
}

5. Check from the frontend

vue
<!-- 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

php
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:sync run
  • [ ] 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

CPR - Clinical Patient Records