Skip to content

Dynamic Roles (Hybrid RBAC)

Laravel Access uses an Enum-First, Code-Driven paradigm. Developers define permissions strictly in code using PHP Backed Enums. This guarantees type-safety, IDE autocomplete, and clean static analysis.

However, in many B2B or SaaS applications, you need to allow end-users (e.g. Workspace Admins) to create custom roles (like "Assistant Manager" or "Night Shift Staff") and choose which permissions those roles have.

Laravel Access handles this elegantly using a Hybrid RBAC Model:

  1. Static Permissions (Enums): Defined by developers in PHP code.
  2. Dynamic Roles (Database): Created and customized by end-users at runtime.

1. The Dynamic Roles Schema

To support user-created roles, your access_roles table includes fields to track whether a role is developer-defined or user-defined, and what tenant/scope model it belongs to:

  • is_system (boolean): true for system-wide roles defined in config/access.php, false for user-created custom roles.
  • scope_type / scope_id: Populated if a role is created specifically within a scoped tenant model (e.g., a specific Workspace or Team).

2. Creating a Custom Scoped Role

To allow a tenant administrator to create a custom role within their scope, use the scoped createRole method on the user context. The library automatically scopes the role, sets is_system => false, and sets is_global => false:

NOTE

In all of the examples below, the $scope variable refers to a concrete instance of your application's scoped tenant model (for example, a Workspace, Team, or Company instance).

php
// Create the role within the specific scope using a string name
$role = $user->in($scope)->createRole('editor-assistant', 'Editor Assistant', 'Help with editing articles');

You can also pass a BackedEnum as the role name. The enum's value is used as the role name:

php
use App\Enums\CompanyRole;

$role = $user->in($scope)->createRole(CompanyRole::Admin, 'Company Admin');

3. Syncing & Editing Permissions of a Scoped Role

Present your tenant administrators with a checkbox list of the static System Permissions defined in your enum. When they save their role, sync the pivot mappings using syncRolePermissions.

While standard strings are fully supported, using a PHP Backed Enum is highly recommended:

php
use App\Enums\Permission;

$user->in($scope)->syncRolePermissions('editor-assistant', [
    Permission::PostCreate,
    Permission::PostEdit,
]);

You can also reference a role by its BackedEnum value when the role name matches an enum case:

php
use App\Enums\CompanyRole;
use App\Enums\Permission;

$user->in($scope)->syncRolePermissions(CompanyRole::Admin, [
    Permission::CompanyMembersInvite,
    Permission::CompanySettingsManage,
]);

Adding & Removing Individual Permissions

Alternatively, if you want to add or remove individual permissions from a role dynamically without replacing the entire list, use addPermissionToRole and removePermissionFromRole:

php
use App\Enums\Permission;

// Add a single permission to a dynamic role
$user->in($scope)->addPermissionToRole('editor-assistant', Permission::PostCreate);

// Remove a single permission from a dynamic role
$user->in($scope)->removePermissionFromRole('editor-assistant', Permission::PostCreate);

4. Assigning and Checking Access

Checking user access with scoped roles is identical to checking static roles. Laravel Access resolves the mappings dynamically at runtime.

For all developer-defined roles, using PHP Backed Enums is highly recommended for IDE autocompletion, type safety, and clean static analysis:

php
use App\Enums\CompanyRole;

// Assign the role cleanly using the enum case
$user->in($scope)->assignRole(CompanyRole::Admin);

// Check role assignment
if ($user->in($scope)->hasRole(CompanyRole::Admin)) {
    // ...
}

Supported: Strings for Runtime User-Created Roles

If a custom role is created dynamically by an end-user at runtime (where no compile-time PHP enum exists), you can pass its standard string name instead:

php
// Assign the dynamically created role by string name
$user->in($scope)->assignRole('custom-role-name');

// Check the dynamic role assignment
if ($user->in($scope)->hasRole('custom-role-name')) {
    // ...
}

In B2B apps, you should check permissions instead of roles in your policies and controllers. This ensures that even if a tenant admin changes the permissions of the editor-assistant role, your authorization logic remains 100% correct:

php
// Check the permission (automatically resolves through the custom role mapping)
if ($user->in($scope)->can(Permission::PostCreate)) {
    // Authorized!
}

5. Listing and Deleting Dynamic Roles

To retrieve the applicable roles inside the current scope (including both system roles and custom workspace-specific roles), call roles():

php
// Get all roles available in this scope
$roles = $user->in($scope)->roles();

To delete a role within the current scope:

php
$user->in($scope)->deleteRole('editor-assistant');

You can also pass a BackedEnum for developer-defined roles:

php
use App\Enums\CompanyRole;

$user->in($scope)->deleteRole(CompanyRole::Admin);

6. Protecting Dynamic Role Administration

To authorize users to create/edit roles, you should define a static meta-permission in your enum, e.g. Permission::RolesManage.

You can then write standard Laravel Model Policies to protect your role administration endpoints:

php
namespace App\Policies;

use App\Enums\Permission;
use App\Models\User;
use Maxiviper117\Access\Models\Role;

class RolePolicy
{
    /**
     * Can a user create a custom role?
     */
    public function create(User $user, $scope): bool
    {
        return $user->in($scope)->can(Permission::RolesManage);
    }

    /**
     * Can a user edit a custom role?
     */
    public function update(User $user, Role $role, $scope): bool
    {
        // System-wide developer-defined roles cannot be edited by users
        if ($role->is_system) {
            return false;
        }

        return $role->scope_id === $scope->getKey()
            && $user->in($scope)->can(Permission::RolesManage);
    }

    /**
     * Can a user delete a custom role?
     */
    public function delete(User $user, Role $role, $scope): bool
    {
        if ($role->is_system) {
            return false;
        }

        return $role->scope_id === $scope->getKey()
            && $user->in($scope)->can(Permission::RolesManage);
    }
}

7. Coexistence with access:sync

Running the developer command php artisan access:sync keeps all developer-defined permissions and config roles in sync with your database.

It is designed to be safe for dynamic roles:

  • Configured system roles are synced and marked as is_system = true.
  • When using the --prune option, the sync command only prunes developer-defined roles (where is_system = true).
  • All dynamic, user-created roles (is_system = false) are left completely untouched in the database.

8. Dynamic Role Actions (Action Pattern)

When you run php artisan access:install, Laravel Access automatically scaffolds five reusable, standalone Action classes in your project under the App\Actions\Access namespace:

  • CreateRole
  • SyncRolePermissions
  • AddPermissionToRole
  • RemovePermissionFromRole
  • DeleteRole

These classes follow the Single Responsibility Principle (SRP), encapsulating all dynamic role validation, database writes, and cache invalidation.

TIP

Native Backed Enum & String Support Just like the core AccessContext APIs, all scaffolded Action stubs natively support passing a BackedEnum, string, or Eloquent Role instance as the role identifier! The actions will automatically resolve the correct database role record in the scope.

Using the Actions

Creating a Role

Use CreateRole::run() to create custom dynamic roles. You can pass a BackedEnum or string for the name. If label is omitted, it will be automatically converted to a developer-friendly headline string (e.g. 'editor-assistant' becomes Editor Assistant):

php
use App\Actions\Access\CreateRole;

// Create a scoped custom role using a string name
$role = CreateRole::run(
    name: 'editor-assistant',
    description: 'Help with editing articles',
    scope: $scope
);

// Create a global custom role by string
$role = CreateRole::run(
    name: 'support-agent',
    label: 'Support Agent',
    description: 'Global support role'
);

You can also pass a BackedEnum for the role name. The enum's value is used:

php
use App\Actions\Access\CreateRole;
use App\Enums\CompanyRole;

$role = CreateRole::run(
    name: CompanyRole::Admin,
    scope: $scope
);

Syncing Permissions to a Role

Use SyncRolePermissions::run() to map developer-defined enum permissions onto custom roles. The role argument accepts a BackedEnum, string, or Role model instance:

php
use App\Actions\Access\SyncRolePermissions;
use App\Enums\Permission;

SyncRolePermissions::run(
    role: 'editor-assistant',
    permissions: [
        Permission::PostCreate,
        Permission::PostEdit,
    ],
    scope: $scope
);

Adding a Single Permission to a Role

Use AddPermissionToRole::run() to add a single permission to a dynamic role:

php
use App\Actions\Access\AddPermissionToRole;
use App\Enums\Permission;

AddPermissionToRole::run(
    role: 'editor-assistant',
    permission: Permission::PostCreate,
    scope: $scope
);

Removing a Single Permission from a Role

Use RemovePermissionFromRole::run() to remove a single permission from a dynamic role:

php
use App\Actions\Access\RemovePermissionFromRole;
use App\Enums\Permission;

RemovePermissionFromRole::run(
    role: 'editor-assistant',
    permission: Permission::PostCreate,
    scope: $scope
);

Deleting a Role

Use DeleteRole::run() to safely delete dynamic roles. This action accepts a BackedEnum, string, or Role model instance:

php
use App\Actions\Access\DeleteRole;

DeleteRole::run('editor-assistant', $scope);