offload-project / laravel-mandate
Unified authorization management for Laravel - roles, permissions, and feature flags in a type-safe API
Installs: 338
Dependents: 0
Suggesters: 0
Security: 0
Stars: 2
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
This package is auto-updated.
Last update: 2026-01-10 06:22:26 UTC
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.
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
# Create a permission php artisan mandate:permission article:edit php artisan mandate:permission article:edit --guard=api # Create a role php artisan mandate:role editor php artisan mandate:role editor --permissions=article:edit,article:view # Create a capability (requires capabilities.enabled = true) php artisan mandate:capability manage-posts php artisan mandate:capability manage-posts --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
# Create a capability php artisan mandate:capability manage-posts php artisan mandate:capability manage-posts --guard=api php artisan mandate:capability manage-posts --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.
Seeding Role Assignments
Configure role-permission assignments in the config file:
// config/mandate.php 'code_first' => [ 'enabled' => true, 'assignments' => [ 'admin' => [ 'permissions' => ['article:*', 'user:*'], 'capabilities' => ['content-management'], ], 'editor' => [ 'permissions' => ['article:view', 'article:edit'], ], ], ],
Then sync with the --seed flag:
php artisan mandate:sync --seed
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:make:permission ArticlePermissions php artisan mandate:make:permission ArticlePermissions --guard=api # Generate a role class php artisan mandate:make:role SystemRoles # Generate a capability class php artisan mandate:make: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 });
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.assignments |
[] |
Role-permission/capability assignments |
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.