Permissions and Roles for Extensions

Authorisation in TruCMS is Spatie roles and permissions checked through Laravel's Gate. A plugin creates its permissions in a migration or seeder, gates its routes inline, and lets site owners assemble roles in the admin UI.

How the pieces fit

User ---has---> Role ---has---> Permission  (string name, guard "web")
  |                                  ^
  +-------- or directly -------------+

Gate::before: user with role id dotartisan.super_admin_role (default 1) => every ability allowed
Check anywhere:   $user->can('manage testimonials')     @can('manage testimonials')    ->can('...') on a route

1. Create the permissions

Create them in a migration so they exist on every site that runs your plugin and so a later version can add more. (Core permissions are seeded by RolePermissionsSeeder; Booking seeds with BookingRolesAndPermissionsSeeder and adds later permissions with migrations.)

Modules/Testimonials/database/migrations/2026_10_21_000002_add_testimonials_permissions.php (excerpt)

foreach ($this->permissions as $name => $description) {
    $permission = Permission::firstOrCreate(['name' => $name, 'guard_name' => 'web']);
    $permission->update([
        'title' => ucfirst($name),
        'group' => 'testimonials',
        'description' => $description,
    ]);
}

app(PermissionRegistrar::class)->forgetCachedPermissions();   // Spatie caches the permission list

2. Gate routes

routes/admin.php

Route::get('/', [TestimonialController::class, 'index'])->name('index')->can('manage testimonials');
Route::post('/', [TestimonialController::class, 'store'])->name('store')->can('create testimonials');
Route::delete('{testimonial}', [TestimonialController::class, 'destroy'])->name('destroy')->can('delete testimonials');

A permission that does not exist fails closed

For anyone but the super admin, can('typo') is simply false — no exception, a silent 403. The core widgets screen currently checks view widgets / edit widgets / delete widgets, names that no seeder creates, so only the super admin can open it (see Known quirks). Test every permission name you use.

3. Gate the UI

@can('create testimonials')
    <a href="{{ route('admin.testimonials.create') }}" class="btn btn-primary btn-sm">@lang('testimonials::testimonials.create')</a>
@endcan

Hide sidebar entries (Auth::user()?->can(...) in your menu helper) and dashboard rows (permission: argument) the same way. Hiding is cosmetic; the route check is the security.

4. Ship ready-made roles (optional)

Booking creates booking-admin, booking-manager, booking-provider and booking-customer and syncs permissions to each. Do the same for your own roles from a seeder or migration:

use App\Models\Role;

$editor = Role::updateOrCreate(
    ['name' => 'testimonials-editor', 'guard_name' => 'web'],
    ['description' => 'Can add and edit testimonials, but not delete them.'],
);
$editor->syncPermissions(['manage testimonials', 'create testimonials', 'edit testimonials']);

app(PermissionRegistrar::class)->forgetCachedPermissions();

Assign with $user->assignRole('testimonials-editor') or in Users → Users → Edit. Site owners can then clone and tweak roles in Users → Roles.

5. Front-end audiences

Testing authorisation

The project's Pest helpers (defined in tests/Pest.php) build users quickly:

HelperReturns
regularUser(array $attrs = [])A user with no permissions
superAdmin(array $attrs = [])A user holding the Super Admin role (and sets dotartisan.super_admin_role)
userWithPermissions(['a', 'b'], $attrs = [])A user with exactly those permissions (creates any that are missing)
it('separates create, edit and delete permissions', function (): void {
    $item = Testimonial::factory()->create();
    $viewer = userWithPermissions(['manage testimonials']);

    $this->actingAs($viewer)->get(route('admin.testimonials.create'))->assertForbidden();
    $this->actingAs($viewer)->delete(route('admin.testimonials.destroy', $item))->assertForbidden();

    $this->actingAs(userWithPermissions(['delete testimonials']))
        ->delete(route('admin.testimonials.destroy', $item))
        ->assertRedirect(route('admin.testimonials.index'));
});

A route-level check across a module is a good extra test: iterate app('router')->getRoutes() and assert each admin.myalias.* route has a can: middleware (Booking's authorization test does this).

Checklist

  1. Permission names are lower-case phrases, grouped with a group slug, created in a migration/seeder, cache forgotten.
  2. Every route has ->can() (or a group-level permission:).
  3. Sidebar, buttons and dashboard rows are hidden for users who cannot use them.
  4. Tests prove 403 for the wrong permission and success for the right one.
  5. No logic depends on a role name (site owners rename roles); depend on permissions.