A role-based access control (RBAC) package for Laravel with a clean, intuitive API.
- 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
- 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
composer require offload-project/laravel-mandate# Core migrations (permissions, roles, pivot tables)
php artisan vendor:publish --tag=mandate-migrations
php artisan migrateThat's it. No configuration required for most applications.
Publish the config file for customization:
php artisan vendor:publish --tag=mandate-configOptional 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-metaAdd 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// 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']// 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$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'); // trueDefine 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// 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');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');@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('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 --}}
@endhasallpermissionsFor 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()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>
@endcanFilter 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();# 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 capabilitiesPublish the config file for customization:
php artisan vendor:publish --tag=mandate-config| 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 |
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.
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.
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'); // trueWildcard syntax:
*matches all at that level:article:*matchesarticle:view,article:edit- Multiple parts:
article:view,editmatches botharticle:viewandarticle:edit
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.
First, publish and run the capability migrations:
php artisan vendor:publish --tag=mandate-migrations-capabilities
php artisan migrateThen enable in config:
// config/mandate.php
'capabilities' => [
'enabled' => true,
'direct_assignment' => false, // Allow assigning capabilities directly to users
],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'));$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// 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 onlyWhen 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 onlyEnable 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@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# 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=apiContext 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.
// config/mandate.php
'context' => [
'enabled' => true,
'global_fallback' => true, // Check global permissions when context check fails
],Run the context migration after enabling:
php artisan migratePass 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// 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);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)// 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 teamQuery 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');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| Option | Default | Description |
|---|---|---|
context.enabled |
false |
Enable context model support |
context.global_fallback |
true |
Check global when context-specific check fails |
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.
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.
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'
],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);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.
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);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);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',
],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);| 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 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.
// config/mandate.php
'code_first' => [
'enabled' => true,
'paths' => [
'permissions' => app_path('Permissions'),
'roles' => app_path('Roles'),
'capabilities' => app_path('Capabilities'),
],
],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';
}<?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';
}| 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.
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 --forceThe sync is additive only — it never deletes database records to prevent data loss.
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 madeUse 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");
}
}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 --seedThe --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)
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).
To store labels and descriptions in the database, publish and run the metadata migration:
php artisan vendor:publish --tag=mandate-migrations-meta
php artisan migrateThis 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.
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 ContentCapabilitiesCustomize the generated stubs:
php artisan vendor:publish --tag=mandate-stubsGenerate 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 --rolesConfigure 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];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;
}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
});| Option | Default | Description |
|---|---|---|
assignments |
[] |
Role-permission/capability assignments (works with or without code-first) |
| 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 |
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' guardEnable 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
}
}
}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 |
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();Publish the language files to customize messages:
php artisan vendor:publish --tag=mandate-langEdit 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.
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);
}
}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,
],In tests, reset permissions cache between tests:
use OffloadProject\Mandate\MandateRegistrar;
protected function setUp(): void
{
parent::setUp();
app(MandateRegistrar::class)->forgetCachedPermissions();
}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.
- PHP 8.2+
- Laravel 11.x or 12.x
MIT License. See LICENSE for details.