Roles & permissions (RBAC)
View on GitHubAdd a new system role tier
Worked example: adding a fifth tier, Contributor, sitting between Member and Viewer — it can view everything a Member can and create issues/comments, but can't delete anything or manage members/roles/ settings.
A tier is just a system role (roles.is_system = true,
roles.role = <RoleType value>) that every project owns exactly one
row of, so adding one is mostly data: a new RoleType case, its
default permission set in RoleService, and a data migration that
creates the new system role in every existing project (new projects
get it automatically from ensureSystemRoles()). There are no
tier lists to maintain in Policies or Controllers — every check is
Project::hasPermission($user, Permission::X), so the new tier is
authorized purely by the permissions on its role. No schema change is
needed either: roles.role is a plain string, so a new RoleType
case is valid the moment the enum knows about it.
For the much shorter "gate a new action" case see
./01-add-a-new-permission.mdRoles & permissions (RBAC)Add a new permissionThe general, step-by-step pattern for adding any new projects./issues./comments. permission and getting it to show up correctly in Settings → Roles & manag….
Step 1 — Add the enum case
File: app/Enums/Permissions/RoleType.php
<?php
namespace App\Enums\Permissions;
enum RoleType: string
{
case OWNER = 'owner';
case ADMIN = 'admin';
case MEMBER = 'member';
case CONTRIBUTOR = 'contributor';
case VIEWER = 'viewer';
case CUSTOM = 'custom';
}
Step 2 — Register it as a system tier and decide its default permissions
File: app/Services/RoleService.php
Add the case to SYSTEM_ROLE_TYPES (this is what makes
ensureSystemRoles() create/seed a roles row for it on every
project) and give it its own *_DEFAULT_PERMISSIONS constant:
private const array SYSTEM_ROLE_TYPES = [
RoleType::OWNER,
RoleType::ADMIN,
RoleType::MEMBER,
RoleType::CONTRIBUTOR,
RoleType::VIEWER,
];
/**
* A step down from Member: can view and contribute (issues, comments)
* but can't delete anything or touch members/roles/settings.
*/
private const array CONTRIBUTOR_DEFAULT_PERMISSIONS = [
Permission::PROJECT_VIEW,
Permission::MEMBERS_VIEW,
Permission::ROLES_VIEW,
Permission::INTEGRATIONS_VIEW,
Permission::ISSUES_VIEW,
Permission::ISSUES_CREATE,
Permission::ISSUES_UPDATE,
Permission::COMMENTS_CREATE,
Permission::COMMENTS_UPDATE_OWN,
];
Then add the matching arm to defaultPermissionIdsFor():
private function defaultPermissionIdsFor(RoleType $role): array
{
$keys = match ($role) {
RoleType::OWNER, RoleType::ADMIN => array_map(fn (Permission $permission) => $permission->value, Permission::cases()),
RoleType::MEMBER => array_map(fn (Permission $permission) => $permission->value, self::MEMBER_DEFAULT_PERMISSIONS),
RoleType::CONTRIBUTOR => array_map(fn (Permission $permission) => $permission->value, self::CONTRIBUTOR_DEFAULT_PERMISSIONS),
RoleType::VIEWER => array_map(fn (Permission $permission) => $permission->value, self::VIEWER_DEFAULT_PERMISSIONS),
RoleType::CUSTOM => [],
};
return PermissionModel::query()->whereIn('key', $keys)->pluck('id')->all();
}
Nothing else in RoleService needs to change — ensureSystemRoles(),
syncSystemRoleForMember(), createRole(), syncPermissions(), and
the Owner/system-role guards all operate on SYSTEM_ROLE_TYPES/
RoleType generically.
Step 3 — Policies need no change
Unlike the old tier-list model, there is nothing to audit here:
IssuePolicy, CommentPolicy, ProjectPolicy and
SettingsController all call hasPermission() and nothing else, so
Contributor can create issues and comments (and edit its own comments)
because CONTRIBUTOR_DEFAULT_PERMISSIONS from Step 2 contains
ISSUES_CREATE, ISSUES_UPDATE, COMMENTS_CREATE and
COMMENTS_UPDATE_OWN, and cannot delete or manage anything because it
doesn't contain those. The judgment call that used to be repeated in
every Policy now lives in exactly one place: the default permission
set you chose in Step 2 (which admins can then adjust per project in
Settings → Roles — editing any system role except Owner really
changes what its members can do).
Two places do still look at the tier string itself, via
Project::roleOf(): an issue type's restricted_role_types
(IssuePolicy::createOfType()) stores tier strings, so the new tier
simply becomes an extra value an admin may pick there, and the
display code (badges, dropdowns) that reads the tier. Check
restricted_role_types handling if Contributor should be selectable.
Step 4 — Make it assignable
File: app/Http/Controllers/ProjectMemberController.php and
app/Http/Controllers/ProjectInvitationController.php
Both already validate the incoming role with
Rule::enum(RoleType::class)->except([RoleType::OWNER, RoleType::CUSTOM])
— a deny-list, not an allow-list, so a brand new case is assignable
automatically. Nothing to change here unless you want Contributor to
be unassignable through these flows (unlikely), in which case add it
to the except([...]) array in both files.
Step 5 — Frontend types and theming
File: resources/js/types/Roles.ts
export type RoleTypeValue =
| 'owner'
| 'admin'
| 'member'
| 'contributor'
| 'viewer'
| 'custom';
File: resources/js/types/ProjectMembers.ts
export type ProjectMemberRole =
| 'owner'
| 'admin'
| 'member'
| 'contributor'
| 'viewer';
export type AssignableProjectMemberRole = Exclude<ProjectMemberRole, 'owner'>;
File: resources/js/utils/roleTheme.ts — add a contributor entry to
ROLE_TYPE_THEME (this is what every role badge, the tier dropdown,
and the roles sidebar read to render a label/color/icon — miss this
and Contributor renders with undefined styling wherever it's typed
correctly but has no theme entry):
contributor: {
label: 'Contributor',
dot: 'bg-teal-400',
ring: 'stroke-teal-400',
badgeClass: 'bg-teal-400/10 text-teal-400',
gradient: 'from-teal-400/20 to-teal-400/5',
icon: 'PenLine',
},
Step 6 — Backfill the system role for existing projects
ensureSystemRoles() creates the Contributor role for a project the
next time it runs there (project creation, a member's role change, an
invitation), but until then an existing project simply has no
Contributor role, and an admin couldn't pick it. Create it everywhere
with a data migration, modelled on
database/migrations/2026_10_13_100000_backfill_system_roles_for_existing_members.php:
return new class extends Migration
{
public function up(): void
{
$roleService = app(RoleService::class);
Project::query()->chunkById(100, function ($projects) use ($roleService) {
foreach ($projects as $project) {
$roleService->ensureSystemRoles($project);
}
});
CacheVersion::bump(CacheVersion::MEMBERSHIP);
}
public function down(): void {}
};
ensureSystemRoles() is idempotent: it only seeds default permissions
for a role it has just created, so existing Owner/Admin/Member/Viewer
roles — and any customization admins made to them — are left alone.
Nobody is assigned the new role by this migration; members move onto
it through the normal role-change flow
(RoleService::syncSystemRoleForMember()).
The permissions table itself needs no change for a new tier (it is
already fully seeded via PermissionSeeder, see
./01-add-a-new-permission.mdRoles & permissions (RBAC)Add a new permissionThe general, step-by-step pattern for adding any new projects./issues./comments. permission and getting it to show up correctly in Settings → Roles & manag…
step 6). To see the role immediately on an existing local project
without migrating:
php artisan tinker --execute="app(App\Services\RoleService::class)->ensureSystemRoles(App\Models\Project::first())"
# or, inside Docker:
make shell
php artisan tinker --execute="app(App\Services\RoleService::class)->ensureSystemRoles(App\Models\Project::first())"
Step 7 — Tests
tests/Feature/RoleServiceTest.php— the'it creates the owner, admin, member and viewer system roles with their default permissions'test asserts the exact tier key list (['owner', 'admin', 'member', 'viewer']) and the exact "idempotent, 4 system roles" count in'it is idempotent and does not duplicate system roles'— both need updating to 5/contributorincluded.tests/Feature/ProjectMemberServiceTest.php/tests/Feature/ProjectMemberControllerTest.php— add a "can promote a member to contributor" test mirroring the existing "can promote a member to admin" one, and confirmsyncSystemRoleForMemberswaps the pivot correctly for the new tier. In tests add members withaddProjectMember($project, $userId, 'contributor')fromtests/Pest.php(membership row + system role), neverattach(['role' => ...]).tests/Feature/Models/RoleTest.php— if it enumeratesRoleTypecases anywhere, add Contributor to the expected list.- Behavior of the tier itself is covered through the Controller tests
of the actions it can/can't perform — add "a contributor can create
an issue but cannot delete one" to
tests/Feature/IssueControllerTest.php, mirroring the existing Member-tier tests. No Policy was changed, so this exercisesCONTRIBUTOR_DEFAULT_PERMISSIONSend to end. resources/js/utils/roleTheme.test.ts(if present) or whichever test iteratesROLE_TYPE_THEME/RoleTypeValue— addcontributorso a missing theme entry fails loudly instead of rendering blank.
