offload-project / laravel-mandate
Unified authorization management for Laravel - roles, permissions, and feature flags in a type-safe API
Installs: 932
Dependents: 0
Suggesters: 0
Security: 0
Stars: 5
Watchers: 0
Forks: 0
Open Issues: 0
pkg:composer/offload-project/laravel-mandate
Requires
- php: ^8.2
- illuminate/console: ^11.0|^12.0
- illuminate/contracts: ^11.0|^12.0
- illuminate/support: ^11.0|^12.0
Requires (Dev)
- captainhook/captainhook-phar: ^5.27
- larastan/larastan: ^3.8.1
- laravel/pint: ^1.26.0
- mockery/mockery: 1.7.x-dev
- orchestra/testbench: ^9.0|^10.8.0
- pestphp/pest: ^3.0|^4.0
- pestphp/pest-plugin-laravel: ^3.0|^4.0
- ramsey/conventional-commits: ^1.6
README
Laravel Mandate
A role-based access control (RBAC) package for Laravel with a clean, intuitive API.
Features
- Roles & Permissions — Assign roles to users, grant permissions to roles or directly to users
- Capabilities — Group permissions into semantic capabilities for cleaner authorization logic
- Multi-Tenancy — Scope roles and permissions to context models (Team, Organization, Project)
- Feature Integration — Delegate feature access checks to external packages (Flagged, etc.)
- Wildcard Permissions — Pattern matching with
article:*or*.editsyntax - Multiple Guards — Scope authorization to different authentication guards
- Laravel Gate — Automatic registration with Laravel's authorization system
- Blade Directives —
@role,@permission,@capability, and more - Route Middleware — Protect routes with
permission:,role:, orrole_or_permission: - Fluent Builder — Expressive chained authorization checks
- Query Scopes — Filter models by role or permission
- UUID/ULID Support — Use any primary key type for all models
- Caching — Built-in permission caching with automatic invalidation
- Events — Hook into role, permission, and capability changes
- Artisan Commands — Create and manage roles, permissions, and capabilities from CLI
- Code-First Definitions — Define permissions, roles, and capabilities in PHP classes with attributes
Table of Contents
- Installation
- Quick Start
- Usage
- Protecting Routes
- Blade Directives
- Fluent Authorization Builder
- Laravel Gate Integration
- Query Scopes
- Artisan Commands
- Configuration
- Capabilities
- Context Model (Multi-Tenancy)
- Feature Integration
- Code-First Definitions
- Multiple Guards
- Events
- Exceptions
- Extending Models
- Testing
- Upgrading from 1.x
- Requirements
- License
Installation
composer require offload-project/laravel-mandate
# Core migrations (permissions, roles, pivot tables)
php artisan vendor:publish --tag=mandate-migrations
php artisan migrate
That's it. No configuration required for most applications.
Publish the config file for customization:
php artisan vendor:publish --tag=mandate-config
Optional migrations (publish only what you need):
# Capabilities feature (semantic permission groups) php artisan vendor:publish --tag=mandate-migrations-capabilities # Metadata columns (label/description for permissions, roles, capabilities) php artisan vendor:publish --tag=mandate-migrations-meta
Quick Start
Add the trait to any Eloquent model that needs roles and permissions (User, Team, etc.):
use OffloadProject\Mandate\Concerns\HasRoles; class User extends Authenticatable { use HasRoles; }
Create roles and permissions, then assign them:
use OffloadProject\Mandate\Models\Permission; use OffloadProject\Mandate\Models\Role; // Create a role with permissions $admin = Role::create(['name' => 'admin']); $admin->grantPermission(Permission::create(['name' => 'article:edit'])); // Assign to a user $user->assignRole('admin'); // Check authorization $user->hasPermission('article:edit'); // true $user->hasRole('admin'); // true
Usage
Roles
// Assign roles $user->assignRole('editor'); $user->assignRole(['editor', 'moderator']); // Remove roles $user->removeRole('editor'); // Replace all roles $user->syncRoles(['editor', 'moderator']); // Check roles $user->hasRole('admin'); // Has this role? $user->hasAnyRole(['admin', 'editor']); // Has any of these? $user->hasAllRoles(['admin', 'editor']); // Has all of these? $user->hasExactRoles(['editor', 'moderator']); // Has exactly these (no more, no less)? // Get role names $user->getRoleNames(); // Collection: ['editor', 'moderator']
Permissions
// Grant permissions directly to a user $user->grantPermission('article:publish'); $user->grantPermission(['article:publish', 'article:delete']); // Revoke permissions $user->revokePermission('article:publish'); // Replace all direct permissions $user->syncPermissions(['article:view', 'article:edit']); // Check permissions (checks both direct and role-based) $user->hasPermission('article:edit'); $user->hasAnyPermission(['article:edit', 'article:delete']); $user->hasAllPermissions(['article:edit', 'article:delete']); // Check only direct permissions (ignores role-based) $user->hasDirectPermission('article:edit'); // Get all permissions $user->getAllPermissions(); // Direct + via roles $user->getDirectPermissions(); // Direct only $user->getPermissionsViaRoles(); // Via roles only
Assigning Permissions to Roles
$role = Role::findByName('editor'); $role->grantPermission('article:edit'); $role->grantPermission(['article:edit', 'article:publish']); $role->revokePermission('article:publish'); $role->syncPermissions(['article:view', 'article:edit']); $role->hasPermission('article:edit'); // true
Using PHP Enums
Define permissions or roles as enums for type safety:
enum Permission: string { case ViewArticles = 'article:view'; case EditArticles = 'article:edit'; case DeleteArticles = 'article:delete'; } // Use enum values anywhere $user->grantPermission(Permission::EditArticles); $user->hasPermission(Permission::EditArticles); // true
Protecting Routes
Middleware
// Single permission Route::get('/articles', [ArticleController::class, 'index']) ->middleware('permission:article:view'); // Multiple permissions (user must have ANY) Route::get('/admin', [AdminController::class, 'index']) ->middleware('permission:admin:access|admin:view'); // Role-based Route::get('/dashboard', [DashboardController::class, 'index']) ->middleware('role:admin'); // Role OR permission (user needs any one) Route::get('/reports', [ReportController::class, 'index']) ->middleware('role_or_permission:admin|report:view');
Route Macros
Fluent syntax for route definitions:
Route::get('/articles', [ArticleController::class, 'index']) ->permission('article:view'); Route::get('/admin', [AdminController::class, 'index']) ->role('admin'); Route::get('/reports', [ReportController::class, 'index']) ->roleOrPermission('admin|report:view');
Blade Directives
Role Checks
@role('admin') {{-- User has admin role --}} @endrole @hasrole('admin') {{-- Alias for @role --}} @endhasrole @unlessrole('guest') {{-- User does NOT have guest role --}} @endunlessrole @hasanyrole('admin|editor') {{-- User has admin OR editor --}} @endhasanyrole @hasallroles(['admin', 'editor']) {{-- User has admin AND editor --}} @endhasallroles @hasexactroles(['editor', 'moderator']) {{-- User has exactly these roles --}} @endhasexactroles
Permission Checks
@permission('article:edit') <a href="/articles/edit">Edit</a> @endpermission @haspermission('article:edit') {{-- Alias for @permission --}} @endhaspermission @unlesspermission('article:edit') {{-- User does NOT have permission --}} @endunlesspermission @hasanypermission(['article:edit', 'article:delete']) {{-- User has any of these --}} @endhasanypermission @hasallpermissions(['article:edit', 'article:publish']) {{-- User has all of these --}} @endhasallpermissions
Fluent Authorization Builder
For complex authorization checks, use the fluent builder:
use OffloadProject\Mandate\Facades\Mandate; // Simple checks Mandate::for($user)->can('article:edit'); // Single permission Mandate::for($user)->is('admin'); // Single role // Chained with OR Mandate::for($user) ->hasRole('admin') ->orHasPermission('article:edit') ->check(); // Chained with AND Mandate::for($user) ->hasPermission('article:view') ->andHasRole('editor') ->check(); // Multiple conditions Mandate::for($user) ->hasAnyRole(['admin', 'editor']) ->orHasPermission('article:manage') ->check(); // With context (multi-tenancy) Mandate::for($user) ->inContext($team) ->hasPermission('project:manage') ->check(); // Alternative endings Mandate::for($user)->hasRole('admin')->allowed(); // Alias for check() Mandate::for($user)->hasRole('admin')->denied(); // Inverse of check()
Laravel Gate Integration
Mandate registers permissions with Laravel's Gate automatically:
// In controllers $this->authorize('article:edit'); // Anywhere Gate::allows('article:edit'); Gate::denies('article:edit'); // In Blade (works alongside Mandate directives) @can('article:edit') <a href="/edit">Edit</a> @endcan
Query Scopes
Filter models by role or permission:
// Users with specific role User::role('admin')->get(); User::role(['admin', 'editor'])->get(); // Users without specific role User::withoutRole('banned')->get(); // Users with specific permission User::permission('article:edit')->get(); // Users without specific permission User::withoutPermission('admin:access')->get();
Artisan Commands
# Generate a permission class (code-first) php artisan mandate:permission ArticlePermissions php artisan mandate:permission ArticlePermissions --guard=api # Generate a role class (code-first) php artisan mandate:role SystemRoles # Generate a capability class (code-first) php artisan mandate:capability ContentCapabilities # Create directly in database (use --db flag) php artisan mandate:permission article:edit --db php artisan mandate:role editor --db --permissions=article:edit,article:view php artisan mandate:capability manage-posts --db --permissions=post:create,post:edit # Assign a role to a subject (user, team, etc.) php artisan mandate:assign-role 1 admin php artisan mandate:assign-role 1 admin --model="App\Models\Team" # Assign a capability to a role php artisan mandate:assign-capability editor manage-posts # Display all roles and permissions php artisan mandate:show # Clear permission cache php artisan mandate:cache-clear # Migrate from Spatie Laravel Permission php artisan mandate:upgrade-from-spatie --dry-run # Preview changes php artisan mandate:upgrade-from-spatie # Run migration php artisan mandate:upgrade-from-spatie --create-capabilities # Also create capabilities from prefixes php artisan mandate:upgrade-from-spatie --convert-permission-sets # Convert 1.x #[PermissionsSet] to capabilities
Configuration
Publish the config file for customization:
php artisan vendor:publish --tag=mandate-config
Key Options
| Option | Default | Description |
|---|---|---|
model_id_type |
'int' |
Primary key type: 'int', 'uuid', or 'ulid' |
models.permission |
Permission::class |
Custom permission model |
models.role |
Role::class |
Custom role model |
models.capability |
Capability::class |
Custom capability model |
cache.expiration |
86400 (24h) |
Cache TTL in seconds |
wildcards.enabled |
false |
Enable wildcard permissions |
capabilities.enabled |
false |
Enable capabilities feature |
capabilities.direct_assignment |
false |
Allow direct capability-to-user assignment |
context.enabled |
false |
Enable context model support (multi-tenancy) |
context.global_fallback |
true |
Check global when context check fails |
features.enabled |
false |
Enable feature integration |
features.models |
[] |
Model classes considered Feature contexts |
features.on_missing_handler |
'deny' |
Behavior when handler is not bound |
register_gate |
true |
Register with Laravel Gate |
events |
false |
Fire events on changes |
column_names.subject_morph_name |
'subject' |
Base name for subject morph columns |
column_names.context_morph_name |
'context' |
Base name for context morph columns |
UUID / ULID Primary Keys
Mandate supports UUID or ULID primary keys for all its models. Configure before running migrations:
// config/mandate.php 'model_id_type' => 'uuid', // or 'ulid', default is 'int'
This affects:
permissions,roles, andcapabilitiestables (primary keys)- All pivot tables (foreign keys)
// With UUID enabled, IDs are automatically generated $permission = Permission::create(['name' => 'article:edit']); $permission->id; // "550e8400-e29b-41d4-a716-446655440000" $role = Role::create(['name' => 'admin']); $role->id; // "550e8400-e29b-41d4-a716-446655440001"
Note: Set
model_id_typebefore running migrations. Changing it later requires recreating the tables.
Custom Column Names
Customize morph column names by setting the base name. Mandate automatically appends _id and _type suffixes:
// config/mandate.php 'column_names' => [ 'subject_morph_name' => 'subject', // Creates subject_id, subject_type 'context_morph_name' => 'context', // Creates context_id, context_type ],
For example, to use user instead of subject:
'column_names' => [ 'subject_morph_name' => 'user', // Creates user_id, user_type columns ],
This affects pivot tables (permission_subject, role_subject, capability_subject) and context columns on
permissions/roles tables.
Note: Set column names before running migrations. Changing them later requires recreating the tables.
Wildcard Permissions
Enable pattern-based permission matching:
// config/mandate.php 'wildcards' => [ 'enabled' => true, ],
// Grant wildcard permission $user->grantPermission('article:*'); // Now matches all article permissions $user->hasPermission('article:view'); // true $user->hasPermission('article:edit'); // true $user->hasPermission('article:delete'); // true
Wildcard syntax:
*matches all at that level:article:*matchesarticle:view,article:edit- Multiple parts:
article:view,editmatches botharticle:viewandarticle:edit
Capabilities
Capabilities are semantic groupings of permissions that can be assigned to roles or directly to subjects. This is an optional feature that must be explicitly enabled.
Enabling Capabilities
First, publish and run the capability migrations:
php artisan vendor:publish --tag=mandate-migrations-capabilities php artisan migrate
Then enable in config:
// config/mandate.php 'capabilities' => [ 'enabled' => true, 'direct_assignment' => false, // Allow assigning capabilities directly to users ],
Creating Capabilities
use OffloadProject\Mandate\Models\Capability; // Create a capability with permissions $capability = Capability::create(['name' => 'manage-posts']); $capability->grantPermission(['post:create', 'post:edit', 'post:delete', 'post:publish']); // Or create permissions on the fly $capability = Capability::create(['name' => 'manage-users']); $capability->grantPermission(Permission::findOrCreate('user:view')); $capability->grantPermission(Permission::findOrCreate('user:edit'));
Assigning Capabilities to Roles
$role = Role::findByName('editor'); // Assign capabilities $role->assignCapability('manage-posts'); $role->assignCapability(['manage-posts', 'manage-comments']); // Remove capabilities $role->removeCapability('manage-comments'); // Sync capabilities (replace all) $role->syncCapabilities(['manage-posts']); // Check capabilities $role->hasCapability('manage-posts'); // true
Checking Capabilities on Users
// User gets capabilities through their roles $user->assignRole('editor'); // Check capabilities $user->hasCapability('manage-posts'); $user->hasAnyCapability(['manage-posts', 'manage-users']); $user->hasAllCapabilities(['manage-posts', 'manage-comments']); // Get all capabilities $user->getAllCapabilities(); // Direct + via roles $user->getCapabilitiesViaRoles(); // Via roles only
Permission Resolution Through Capabilities
When you check if a user has a permission, Mandate checks all paths:
- Direct permission - assigned directly to the user
- Via role - role has the permission
- Via capability (through role) - role has a capability that has the permission
- Via capability (direct) - user has a capability directly (if
direct_assignmentenabled)
// All of these work automatically $user->hasPermission('post:edit'); // Checks all paths $user->hasPermissionViaRole('post:edit'); // Checks role + role capabilities $user->hasPermissionViaCapability('post:edit'); // Checks capabilities only
Direct Capability Assignment
Enable direct assignment to allow assigning capabilities directly to user:
// config/mandate.php 'capabilities' => [ 'enabled' => true, 'direct_assignment' => true, ],
// Assign capabilities directly to users $user->assignCapability('manage-posts'); $user->removeCapability('manage-posts'); $user->syncCapabilities(['manage-posts', 'manage-comments']); // Check direct capabilities $user->hasDirectCapability('manage-posts'); $user->getAllCapabilities(); // Includes both direct and via roles
Blade Directives for Capabilities
@capability('manage-posts') {{-- User has manage-posts capability --}} @endcapability @hascapability('manage-posts') {{-- Alias for @capability --}} @endhascapability @hasanycapability('manage-posts|manage-users') {{-- User has any of these capabilities --}} @endhasanycapability @hasallcapabilities(['manage-posts', 'manage-users']) {{-- User has all of these capabilities --}} @endhasallcapabilities
Artisan Commands for Capabilities
# Generate a capability class (code-first) php artisan mandate:capability ContentCapabilities # Create capability directly in database php artisan mandate:capability manage-posts --db php artisan mandate:capability manage-posts --db --guard=api php artisan mandate:capability manage-posts --db --permissions=post:create,post:edit,post:delete # Assign capability to a role php artisan mandate:assign-capability editor manage-posts php artisan mandate:assign-capability editor manage-posts --guard=api
Context Model (Multi-Tenancy)
Context Model enables scoping roles and permissions to a specific model (like Team, Organization, or Project). This allows for resource-specific authorization in multi-tenant applications.
Enabling Context Support
// config/mandate.php 'context' => [ 'enabled' => true, 'global_fallback' => true, // Check global permissions when context check fails ],
Run the context migration after enabling:
php artisan migrate
Assigning Roles and Permissions with Context
Pass a context model as the second parameter:
// Assign a role within a specific team $user->assignRole('manager', $team); // Grant permission within a specific project $user->grantPermission('task:edit', $project); // Assign global role (works across all contexts) $user->assignRole('admin'); // No context = global
Checking Roles and Permissions with Context
// Check if user has role in specific context $user->hasRole('manager', $team); // true $user->hasRole('manager', $otherTeam); // false (if not assigned there) // Check permission with context $user->hasPermission('task:edit', $project); // Check multiple roles/permissions with context $user->hasAnyRole(['manager', 'admin'], $team); $user->hasAllPermissions(['task:view', 'task:edit'], $project);
Global Fallback
When global_fallback is enabled (default), checking permissions with a context will also check global permissions:
// Global permission (no context) $user->grantPermission('report:view'); // With global fallback enabled, this returns true $user->hasPermission('report:view', $team); // Disable global fallback to check only context-specific // config: 'context.global_fallback' => false $user->hasPermission('report:view', $team); // false (no context-specific grant)
Getting Permissions and Roles for Context
// Get roles in a specific context $user->getRolesForContext($team); // Returns roles for this team $user->getRoleNames($team); // Role names in this team // Get permissions for context $user->getAllPermissions($team); // Direct + via roles for this team $user->getPermissionNames($team); // Permission names in this team
Finding Contexts
Query which contexts a user has specific roles or permissions in:
// Get all teams where user is a manager $teams = $user->getRoleContexts('manager'); // Get all projects where user can edit tasks $projects = $user->getPermissionContexts('task:edit');
Using the Mandate Facade with Context
use OffloadProject\Mandate\Facades\Mandate; // Check with context Mandate::hasRole($user, 'manager', $team); Mandate::hasPermission($user, 'task:edit', $project); // Get data with context Mandate::getRoles($user, $team); Mandate::getPermissions($user, $project); // Check if context is enabled Mandate::contextEnabled(); // true/false
Context Configuration Options
| Option | Default | Description |
|---|---|---|
context.enabled |
false |
Enable context model support |
context.global_fallback |
true |
Check global when context-specific check fails |
Feature Integration
Feature Integration enables Mandate to delegate feature access checks to an external package (like Flagged) when a Feature model is used as a context. This allows combining feature flags with permission checks.
How It Works
When you check a permission or role with a Feature model as the context, Mandate first verifies the subject can access the feature before evaluating permissions. This ensures users only get permissions for features they have access to.
Enabling Feature Integration
Feature integration requires context support to be enabled:
// config/mandate.php 'context' => [ 'enabled' => true, ], 'features' => [ 'enabled' => true, 'models' => [ App\Models\Feature::class, ], 'on_missing_handler' => 'deny', // 'allow', 'deny', or 'throw' ],
Implementing the Feature Access Handler
Your feature management package must implement the FeatureAccessHandler contract:
use Illuminate\Database\Eloquent\Model; use OffloadProject\Mandate\Contracts\FeatureAccessHandler; class FlaggedFeatureHandler implements FeatureAccessHandler { public function isActive(Model $feature): bool { // Check if feature is globally active return $feature->is_active; } public function hasAccess(Model $feature, Model $subject): bool { // Check if subject has been granted access to the feature return $feature->subjects()->where('id', $subject->id)->exists(); } public function canAccess(Model $feature, Model $subject): bool { // Combined check: feature must be active AND subject must have access return $this->isActive($feature) && $this->hasAccess($feature, $subject); } }
Register the handler in a service provider:
use OffloadProject\Mandate\Contracts\FeatureAccessHandler; $this->app->bind(FeatureAccessHandler::class, FlaggedFeatureHandler::class);
Permission Checks with Feature Context
When you pass a Feature model as context, Mandate automatically checks feature access first:
$feature = Feature::find(1); // First checks if user can access the feature via FeatureAccessHandler // Then checks if user has the permission within that feature context $user->hasPermission('edit', $feature); // Same automatic check for roles $user->hasRole('editor', $feature);
If feature access is denied, the permission/role check returns false immediately without evaluating the actual
permission.
Bypassing Feature Checks
For admin scenarios where you need to check permissions regardless of feature access:
// Pass bypassFeatureCheck: true to skip the feature access check $user->hasPermission('edit', $feature, bypassFeatureCheck: true); $user->hasRole('editor', $feature, bypassFeatureCheck: true);
Using the Mandate Facade
use OffloadProject\Mandate\Facades\Mandate; // Check if feature integration is enabled Mandate::featureIntegrationEnabled(); // Check if a model is a Feature context Mandate::isFeatureContext($model); // Get the feature access handler $handler = Mandate::getFeatureAccessHandler(); // Feature access checks Mandate::isFeatureActive($feature); Mandate::hasFeatureAccess($feature, $user); Mandate::canAccessFeature($feature, $user);
Missing Handler Behavior
Configure what happens when no FeatureAccessHandler is bound:
| Value | Behavior |
|---|---|
deny |
Return false (fail closed) - Default |
allow |
Return true (fail open) |
throw |
Throw FeatureAccessException |
// config/mandate.php 'features' => [ 'on_missing_handler' => 'deny', ],
Non-Feature Contexts
When checking permissions with a non-Feature context (like Team or Project), feature integration is bypassed entirely:
$team = Team::find(1); // No feature check - works like normal context $user->hasPermission('edit', $team);
Feature Configuration Options
| Option | Default | Description |
|---|---|---|
features.enabled |
false |
Enable feature integration |
features.models |
[] |
Model classes considered Feature contexts |
features.on_missing_handler |
'deny' |
Behavior when handler is not bound |
Code-First Definitions
Code-first allows you to define permissions, roles, and capabilities in PHP classes using attributes, then sync them to the database. This provides better IDE support, version control, and type safety.
Enabling Code-First
// config/mandate.php 'code_first' => [ 'enabled' => true, 'paths' => [ 'permissions' => app_path('Permissions'), 'roles' => app_path('Roles'), 'capabilities' => app_path('Capabilities'), ], ],
Defining Permissions
Create a class with string constants for each permission:
<?php namespace App\Permissions; use OffloadProject\Mandate\Attributes\Description; use OffloadProject\Mandate\Attributes\Guard; use OffloadProject\Mandate\Attributes\Label; #[Guard('web')] class ArticlePermissions { #[Label('View Articles')] #[Description('Allows viewing articles')] public const VIEW = 'article:view'; #[Label('Create Articles')] #[Description('Allows creating new articles')] public const CREATE = 'article:create'; #[Label('Edit Articles')] public const EDIT = 'article:edit'; #[Label('Delete Articles')] public const DELETE = 'article:delete'; }
Defining Roles
<?php namespace App\Roles; use OffloadProject\Mandate\Attributes\Description; use OffloadProject\Mandate\Attributes\Guard; use OffloadProject\Mandate\Attributes\Label; #[Guard('web')] class SystemRoles { #[Label('Administrator')] #[Description('Has all permissions')] public const ADMIN = 'admin'; #[Label('Editor')] #[Description('Can edit content')] public const EDITOR = 'editor'; #[Label('Viewer')] public const VIEWER = 'viewer'; }
Available Attributes
| Attribute | Target | Description |
|---|---|---|
#[Guard] |
Class | Sets the auth guard for all constants |
#[Label] |
Class, Constant | Human-readable name |
#[Description] |
Class, Constant | Longer description |
#[Context] |
Constant | Context model class for scoped permissions |
#[Capability] |
Constant | Assigns permission to a capability |
When #[Label] or #[Description] is on both the class and a constant, the constant-level attribute takes precedence.
Syncing to Database
Use the mandate:sync command to create or update database records from your definitions:
# Sync all definitions php artisan mandate:sync # Sync only permissions php artisan mandate:sync --permissions # Sync only roles php artisan mandate:sync --roles # Sync only capabilities php artisan mandate:sync --capabilities # Preview changes without applying php artisan mandate:sync --dry-run # Sync for specific guard php artisan mandate:sync --guard=api # Skip confirmation in production php artisan mandate:sync --force
The sync is additive only — it never deletes database records to prevent data loss.
Programmatic Sync
Use the Mandate::sync() method to sync definitions programmatically (equivalent to the mandate:sync command):
use OffloadProject\Mandate\Facades\Mandate; // Sync all definitions (permissions, roles, capabilities) $result = Mandate::sync(); // Sync only specific types $result = Mandate::sync(permissions: true); $result = Mandate::sync(roles: true); $result = Mandate::sync(capabilities: true); // Sync with seeding (applies role-permission assignments from config) $result = Mandate::sync(seed: true); // Seed-only mode (works without code-first enabled) // Combine options $result = Mandate::sync(permissions: true, roles: true, seed: true); // Filter by guard $result = Mandate::sync(guard: 'api');
The method returns a SyncResult object with details about what was synced:
$result = Mandate::sync(); $result->permissionsCreated; // Number of permissions created $result->permissionsUpdated; // Number of permissions updated $result->rolesCreated; // Number of roles created $result->rolesUpdated; // Number of roles updated $result->capabilitiesCreated; // Number of capabilities created $result->capabilitiesUpdated; // Number of capabilities updated $result->assignmentsSeeded; // Whether assignments were seeded // Helper methods $result->totalCreated(); // Total items created $result->totalUpdated(); // Total items updated $result->total(); // Total items synced (created + updated) $result->hasChanges(); // Whether any changes were made
Use cases for programmatic sync:
- Database seeders — Sync permissions/roles as part of your seeding process
- Deployment scripts — Automate sync after deployments
- Testing — Set up permissions in test fixtures
- Admin panels — Trigger sync from a UI
// Example: Database seeder class RolesAndPermissionsSeeder extends Seeder { public function run(): void { // Sync code-first definitions and seed assignments $result = Mandate::sync(seed: true); $this->command->info("Created {$result->totalCreated()} items"); } }
Seeding Role Assignments
Configure role-permission assignments in the config file. This works with both code-first and database-only workflows:
// config/mandate.php 'assignments' => [ 'admin' => [ 'permissions' => ['article:*', 'user:*'], 'capabilities' => ['content-management'], ], 'editor' => [ 'permissions' => ['article:view', 'article:edit'], ], ],
Then seed with the --seed flag:
php artisan mandate:sync --seed
The --seed flag will automatically create any roles, permissions, or capabilities that don't exist in the database, then assign permissions to roles as configured. This makes it easy to define your entire RBAC structure in config.
Behavior based on code-first setting:
- Code-first enabled: Syncs PHP class definitions to database first, then seeds assignments
- Code-first disabled: Only seeds assignments (useful for database-only workflows)
Wildcard Assignments (Super Admin)
Use ['*'] to assign all existing permissions or capabilities to a role:
use App\Roles\SystemRoles; 'assignments' => [ SystemRoles::SUPER_ADMIN => [ 'permissions' => ['*'], // Assigns ALL permissions 'capabilities' => ['*'], // Assigns ALL capabilities ], ],
This is useful for super admin roles that should have access to everything. The wildcard assigns all permissions/capabilities that exist in the database at sync time, so make sure to sync your definitions first (or run the full mandate:sync --seed which syncs definitions before seeding assignments).
Label and Description Columns
To store labels and descriptions in the database, publish and run the metadata migration:
php artisan vendor:publish --tag=mandate-migrations-meta php artisan migrate
This adds label and description columns to the permissions, roles, and capabilities tables. These columns are useful
for displaying human-readable names in admin UIs, regardless of whether you use code-first definitions.
Generator Commands
Generate new definition classes with scaffolded constants:
# Generate a permission class with CRUD constants php artisan mandate:permission ArticlePermissions php artisan mandate:permission ArticlePermissions --guard=api # Generate a role class php artisan mandate:role SystemRoles # Generate a capability class php artisan mandate:capability ContentCapabilities
Customize the generated stubs:
php artisan vendor:publish --tag=mandate-stubs
TypeScript Generation
Generate TypeScript types for frontend type safety. The command automatically merges both sources:
- Code-first definitions — PHP classes with attributes (if enabled)
- Database records — Permissions, roles, and capabilities from the database
This allows you to define permissions in code (tied to features) while managing roles in the database ( business-defined).
# Generate to configured location (default: resources/js/types/mandate.ts) php artisan mandate:typescript # Override output path php artisan mandate:typescript --output=resources/js/permissions.ts # Generate only specific types php artisan mandate:typescript --permissions php artisan mandate:typescript --roles
Configure the default output path:
// config/mandate.php 'code_first' => [ 'typescript_path' => resource_path('js/types/mandate.ts'), ],
Grouping behavior:
- Code-first: grouped by source class name (e.g.,
ArticlePermissions) - Database: grouped by prefix (e.g.,
article:view→ArticlePermissions,admin→Roles)
Generated output (mixed sources example):
// Auto-generated by Laravel Mandate - do not edit manually // From code-first PHP class export const ArticlePermissions = { VIEW: "article:view", CREATE: "article:create", EDIT: "article:edit", DELETE: "article:delete", } as const; // From database records (no prefix → grouped as "Roles") export const Roles = { ADMIN: "admin", EDITOR: "editor", MODERATOR: "moderator", } as const; export type Permission = typeof ArticlePermissions[keyof typeof ArticlePermissions]; export type Role = typeof Roles[keyof typeof Roles];
Using Definitions in Code
Reference your code-first constants for type-safe permission checks:
use App\Permissions\ArticlePermissions; // Type-safe permission checks (code-first) $user->hasPermission(ArticlePermissions::EDIT); $user->grantPermission(ArticlePermissions::VIEW); // Database-defined roles (use string names) $user->hasRole('admin'); $user->assignRole('editor');
On the frontend, use the generated TypeScript types:
import {ArticlePermissions, Roles, type Permission, type Role} from '@/types/mandate'; // Type-safe permission checks function canEdit(userPermissions: Permission[]): boolean { return userPermissions.includes(ArticlePermissions.EDIT); } // Type-safe role checks function isAdmin(userRole: Role): boolean { return userRole === Roles.ADMIN; }
Sync Events
Listen to sync events for custom post-sync logic:
use OffloadProject\Mandate\Events\PermissionsSynced; use OffloadProject\Mandate\Events\RolesSynced; use OffloadProject\Mandate\Events\CapabilitiesSynced; use OffloadProject\Mandate\Events\MandateSynced; // Individual sync events Event::listen(PermissionsSynced::class, function ($event) { Log::info("Synced {$event->created} new permissions, {$event->updated} updated"); }); // Aggregate event (fired after all syncs complete) Event::listen(MandateSynced::class, function ($event) { // $event->permissions, $event->roles, $event->capabilities });
Assignments Configuration
| Option | Default | Description |
|---|---|---|
assignments |
[] |
Role-permission/capability assignments (works with or without code-first) |
Code-First Configuration Options
| Option | Default | Description |
|---|---|---|
code_first.enabled |
false |
Enable code-first mode |
code_first.paths.permissions |
app_path('Permissions') |
Directory to scan for permission classes |
code_first.paths.roles |
app_path('Roles') |
Directory to scan for role classes |
code_first.paths.capabilities |
app_path('Capabilities') |
Directory to scan for capability classes |
code_first.typescript_path |
resource_path('js/types/mandate.ts') |
Default output path for TypeScript types |
feature_generator |
null |
Custom feature generator class |
Multiple Guards
Mandate scopes roles and permissions to authentication guards:
// Create role for API guard $role = Role::create(['name' => 'api-admin', 'guard' => 'api']); // Find role by guard $role = Role::findByName('admin', 'api'); // Permissions respect the model's guard $apiUser->hasPermission('api:access'); // Checks against 'api' guard
Events
Enable events to hook into role/permission changes:
// config/mandate.php 'events' => true,
Available events:
| Event | Payload |
|---|---|
RoleAssigned |
$subject, $roles |
RoleRemoved |
$subject, $roles |
PermissionGranted |
$subject, $permissions |
PermissionRevoked |
$subject, $permissions |
CapabilityAssigned |
$subject, $capabilities |
CapabilityRemoved |
$subject, $capabilities |
use OffloadProject\Mandate\Events\RoleAssigned; class SendWelcomeEmail { public function handle(RoleAssigned $event): void { if (in_array('subscriber', $event->roleNames)) { // Send welcome email } } }
Exceptions
Mandate throws descriptive exceptions:
| Exception | When |
|---|---|
RoleNotFoundException |
Role doesn't exist |
RoleAlreadyExistsException |
Creating duplicate role |
PermissionNotFoundException |
Permission doesn't exist |
PermissionAlreadyExistsException |
Creating duplicate permission |
CapabilityNotFoundException |
Capability doesn't exist |
CapabilityAlreadyExistsException |
Creating duplicate capability |
FeatureAccessException |
Feature handler missing (when throw) |
GuardMismatchException |
Permission/role guard doesn't match model |
UnauthorizedException |
Middleware authorization fails |
UnauthorizedException Factory Methods
use OffloadProject\Mandate\Exceptions\UnauthorizedException; // Single role/permission UnauthorizedException::forRole('admin'); UnauthorizedException::forPermission('article:edit'); // Multiple roles/permissions UnauthorizedException::forRoles(['admin', 'editor']); UnauthorizedException::forPermissions(['article:edit', 'article:delete']); // Role or permission (either would satisfy) UnauthorizedException::forRolesOrPermissions(['admin'], ['article:manage']); // Authentication issues UnauthorizedException::notLoggedIn(); UnauthorizedException::notEloquentModel();
Customizing Exception Messages
Publish the language files to customize messages:
php artisan vendor:publish --tag=mandate-lang
Edit lang/vendor/mandate/en/messages.php:
return [ 'not_logged_in' => 'Please sign in to continue.', 'missing_permission' => 'Access denied: requires :permission.', 'missing_permissions' => 'Access denied: requires :permissions.', 'missing_role' => 'Access denied: requires :role role.', 'missing_roles' => 'Access denied: requires :roles roles.', 'missing_role_or_permission' => 'Access denied.', ];
Available placeholders:
| Placeholder | Description |
|---|---|
:permission |
Single permission name |
:permissions |
Comma-separated permission names |
:role |
Single role name |
:roles |
Comma-separated role names |
Messages resolve from translation files first, then fall back to built-in defaults.
Handling Authorization Failures
use OffloadProject\Mandate\Exceptions\UnauthorizedException; // In your exception handler public function render($request, Throwable $e) { if ($e instanceof UnauthorizedException) { // Access required roles/permissions for custom handling $roles = $e->requiredRoles; $permissions = $e->requiredPermissions; return response()->json([ 'error' => 'unauthorized', 'message' => $e->getMessage(), ], 403); } }
Extending Models
Use custom models with UUID/ULID support or additional fields:
use OffloadProject\Mandate\Models\Role as BaseRole; use OffloadProject\Mandate\Contracts\Role as RoleContract; use Illuminate\Database\Eloquent\Concerns\HasUuids; class Role extends BaseRole implements RoleContract { use HasUuids; protected $fillable = ['name', 'guard', 'description']; }
// config/mandate.php 'models' => [ 'role' => App\Models\Role::class, ],
Testing
In tests, reset permissions cache between tests:
use OffloadProject\Mandate\MandateRegistrar; protected function setUp(): void { parent::setUp(); app(MandateRegistrar::class)->forgetCachedPermissions(); }
Upgrading from 1.x
Version 2.x is a complete rewrite of Laravel Mandate. It is now a standalone RBAC package that does not depend on Spatie Laravel Permission.
Major changes:
- Spatie Laravel Permission dependency removed — Mandate is now standalone
- New API — use
$user->hasPermission()instead ofMandate::can($user, ...) #[PermissionsSet]→ Capabilities (assignable permission groups)#[RoleSet]removed — use#[Guard]on classes instead- Code-first is optional — disabled by default, enable via config
- New features — multi-tenancy (Context), wildcard permissions
See UPGRADE.md for detailed migration instructions.
Requirements
- PHP 8.2+
- Laravel 11.x or 12.x
License
MIT License. See LICENSE for details.