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.
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 routeApp\Models\Permission and Role extend Spatie's models. Permissions carry name, title, group, description (the admin Permissions screen groups checkboxes by group); roles carry name, description and a default-guest flag.manage x (see/list), create x, edit x, delete x, and sometimes view x. Add a parent access {alias} if you gate a whole admin area, as TruCommerce and TruPayments do.web.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 listfirstOrCreate/updateOrInsert so re-running is harmless.php artisan permission:cache-reset).down() deletes them. Roles that held them lose them automatically.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');->can() on the route adds Laravel's can: middleware (the project convention). A failing check returns 403.'permission:access myalias' to the group's middleware array, as in TruCommerce's RouteServiceProvider. Spatie's permission: middleware also accepts a|b (any of).TeamPolicy, TicketPolicy) check ownership of an instance; write one only for that case, and register it in your provider with Gate::policy(Model::class, Policy::class).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.
@can('create testimonials')
<a href="{{ route('admin.testimonials.create') }}" class="btn btn-primary btn-sm">@lang('testimonials::testimonials.create')</a>
@endcanHide 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.
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.
setting('public_user_role') on registration (UserRegistered listener), which is where you grant account-area abilities (e.g. Booking's booking-customer).auth + verified and, if needed, TwoFactorMiddleware; routes for staff outside the admin use permission:... (Booking's provider cabinet: permission:access booking staff cabinet).admin, user in a visitor's workspace) are a different system: Roles::role('key', 'Name', [abilities]) from App\Facades\Roles. Do not mix them with Spatie roles.The project's Pest helpers (defined in tests/Pest.php) build users quickly:
| Helper | Returns |
|---|---|
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).
group slug, created in a migration/seeder, cache forgotten.->can() (or a group-level permission:).