Users, roles & permissions
Authorization uses spatie/laravel-permission (^8.3). The same screens work on the central domain (guard web) and inside every tenant (guard tenant), each with its own users, roles and permissions.
How the pieces fit together:
config/permissions/*.php → defines permissions + default roles for each
Role (system or custom) → picked on the user form
PermissionService → gives the user the role's default permissions directly
permission middleware / can() → checks the user's permissionsUsers
/users (module Modules/User):
- List with search, pagination and stats (total, verified, unverified)
- Create, edit and delete users (you cannot delete yourself)
- Pick a role when creating or editing
- Send invitation email instead of setting a password
- Assign permissions dialog: permissions grouped by config file, with a role selector that checks the role's default permissions
| Action | Central permission | Tenant permission |
|---|---|---|
| View list | View Users | View Tenant Users |
| Create | Create User | Create Tenant User |
| Edit | Edit User | Edit Tenant User |
| Delete | Delete User | Delete Tenant User |
| Assign permissions | Assign Permissions | Assign Tenant Permissions |
Invitations
An invitation lets the new user choose their own password. When Send invitation email is on:
UserService::createUser()
→ creates the user with a random password, sets invited_at
→ assigns the selected role
→ queues an email with a signed link (valid 7 days)
User opens the link
→ sets a password (auth/AcceptInvitation)
→ is verified and signed in, invited_at is clearedThe list shows an Invitation pending badge while invited_at is set.
Invitations need a queue worker
Invitation emails are queued. Without a running worker no email is sent. See Local development → Queue worker.
Related files: Modules/User/Services/UserService.php, Modules/User/Notifications/UserInvitationNotification.php (EXPIRES_IN_DAYS), Modules/User/Http/Controllers/UserInvitationController.php (route users.invitation.show).
Roles
/roles (module Modules/RolePermission) lists roles with search and stats (total, system, custom) and lets you create, rename and delete roles.
| Action | Central permission | Tenant permission |
|---|---|---|
| View | View Roles | View Tenant Roles |
| Create | Create Role | Create Tenant Role |
| Edit | Edit Role | Edit Tenant Role |
| Delete | Delete Role | Delete Tenant Role |
There are two kinds of roles:
| System roles | Custom roles | |
|---|---|---|
| Defined in | Modules\RolePermission\Enums\RoleEnum | The Roles page |
| Default permissions | From config/permissions | None |
| Edit or delete in the UI | No (marked as system) | Yes |
System roles:
| Value | Label |
|---|---|
super-admin | Super Admin |
admin | Admin |
manager | Manager |
employee | Employee |
user | User |
The backend also refuses to delete super-admin.
Super admin
Users with the super-admin role pass every check:
- Backend:
AppServiceProviderregisters aGate::beforethat grants every ability. - Frontend:
auth.isSuperAdminmakes everycan()check pass.
Permissions
Permissions are defined in PHP config files, one file per group. The file tells the kit which permissions exist and which system roles get each one by default.
config/permissions/ # central (guard web)
├── dashboard.php menus.php roles.php settings.php tenants.php users.php
└── tenant/ # tenant (guard tenant)
└── dashboard.php menus.php roles.php settings.php users.php// config/permissions/tenants.php
return [
[
'permission_name' => 'View Tenants',
'associated_roles' => ['Super Admin', 'Admin', 'Manager'],
],
];| Key | Meaning |
|---|---|
permission_name | The permission stored in the database |
associated_roles | Role labels (from RoleEnum::label()), not values, that get this permission by default |
PermissionService::getGroupedPermissions() reads the central or tenant folder depending on context. Group names are translated with modules/role.permission_groups.<file name>.
How permissions are assigned
Permissions are assigned directly to users, not to roles. This lets you fine-tune one user without creating a new role. PermissionService::assignRole($user, $roleName) works like this:
| Role type | Result |
|---|---|
| System role | Syncs the role and replaces the user's permissions with those whose associated_roles include the role's label |
| Custom role | Syncs only the role; the user's permissions are not changed. Use the Assign permissions dialog |
Changing a user's role in the edit form re-applies that role's default permissions.
Adding a permission
Add an entry to the right file in
config/permissions/(andconfig/permissions/tenant/if tenants need it).Create it in the database:
bashphp artisan db:seed --class=PermissionSeeder # central php artisan tenants:seed # all tenants (runs TenantDatabaseSeeder)Assign it to users in the UI, or re-assign the role.
If you created a new file, add a group label to
lang/<locale>/modules/role.phpunderpermission_groups.
tenants:seed runs the full tenant seeder
TenantDatabaseSeeder also runs DefaultUserSeeder, which creates (or updates) the <role>@gmail.com users in every tenant. Remove it from database/seeders/tenant/TenantDatabaseSeeder.php if you do not want those accounts, or seed only permissions with php artisan tenants:seed --class="Database\\Seeders\\PermissionSeeder".
Checking permissions
| Where | How |
|---|---|
| Routes | permission middleware |
| PHP | $user->can('Edit User') or Gate::allows(...) (super admins always pass) |
| Menus | The permission column on each menus row; MenuService hides items the user cannot access |
| Frontend | can() helper, powered by the shared props auth.permissions and auth.isSuperAdmin |
Routes: when a route serves both contexts, pipe-separate the central and tenant names:
Route::get('users', [UserController::class, 'index'])
->middleware('permission:View Users|View Tenant Users')
->name('users.index');Frontend: can(...names) returns true if the user has any of the given permissions.
<script setup lang="ts">
import { usePermission } from '@/composables/usePermission';
const { can } = usePermission();
</script>
<template>
<Button v-if="can('Create User', 'Create Tenant User')">New user</Button>
</template>import { usePermission } from '@/hooks/use-permission';
export default function UsersToolbar() {
const { can } = usePermission();
return can('Create User', 'Create Tenant User') ? <Button>New user</Button> : null;
}<script lang="ts">
import { usePermission } from '@/lib/permission';
const { can } = usePermission();
</script>
{#if can('Create User', 'Create Tenant User')}
<Button>New user</Button>
{/if}Hiding is not securing
Frontend checks only hide UI. Always protect the route with permission: middleware too.