offload-project / laravel-mandate
Unified authorization management for Laravel - roles, permissions, and feature flags in a type-safe API
Installs: 320
Dependents: 0
Suggesters: 0
Security: 0
Stars: 2
Watchers: 0
Forks: 0
Open Issues: 1
pkg:composer/offload-project/laravel-mandate
Requires
- php: ^8.4
- illuminate/contracts: ^11.0|^12.0
- illuminate/support: ^11.0|^12.0
- spatie/laravel-data: ^4.18
- spatie/laravel-permission: ^6.24.0
Requires (Dev)
- larastan/larastan: ^3.8.1
- laravel/pennant: ^1.18.5
- laravel/pint: ^1.26.0
- offload-project/laravel-hoist: ^1.0.0
- orchestra/testbench: ^9.0|^10.8.0
- pestphp/pest: ^3.0|^4.0
- pestphp/pest-plugin-laravel: ^3.0|^4.0
This package is auto-updated.
Last update: 2026-01-07 22:21:17 UTC
README
Laravel Mandate
A unified authorization management system for Laravel that brings together roles, permissions, and feature flags into a single, type-safe API. Built on Spatie Laravel Permission. Integrates with Laravel Pennant and Laravel Hoist.
Features
- Unified Authorization: Manage roles, permissions, and feature access through a single API
- Type-Safe: Class-based permissions and roles using constants with PHP attributes
- Role Hierarchy: Define inheritance between roles - child roles automatically inherit parent permissions
- Feature-Gated Access: Tie permissions and roles to feature flags - only active when the feature is enabled
- Auto-Discovery: Automatically discover permission and role classes from configured directories
- Database Sync: Sync discovered permissions and roles to Spatie's database tables with events
- Optional Metadata: Store set, label, and description in the database for UI filtering
- Middleware: Feature-aware route protection out of the box
- Events: Listen to sync operations with
PermissionsSynced,RolesSynced, andMandateSyncedevents - TypeScript Export: Generate TypeScript constants from your PHP permission/role classes for type-safe frontend usage
- Testable: Contracts/interfaces for all registries enable easy mocking in tests
Requirements
- PHP 8.4+
- Laravel 11+
- Spatie Laravel Permission 6.0+
Works With
Mandate integrates with these packages for optional feature flag support:
- Laravel Pennant 1.0+ - Gate permissions/roles behind feature flags
- Laravel Hoist 1.0+ - Enhanced feature flag management
Installation
See the Complete Setup Guide for step-by-step instructions including all dependency setup.
composer require offload-project/laravel-mandate
Publish the configuration:
php artisan vendor:publish --tag=mandate-config
Optionally, publish migrations if you want to store set, label, or description columns:
php artisan vendor:publish --tag=mandate-migrations
Quick Start
Define roles and permissions directly in config - no classes required:
// config/mandate.php 'role_permissions' => [ 'viewer' => [ 'users.view', 'posts.view', ], 'editor' => [ 'users.view', 'posts.view', 'posts.create', 'posts.update', ], 'admin' => [ 'users.*', // Wildcard: all user permissions 'posts.*', // Wildcard: all post permissions ], ],
Then sync to database:
php artisan mandate:sync --seed
That's it! For type-safe constants and IDE autocompletion, see Defining Classes.
Defining Roles and Permissions Using Classes
For larger applications, define permissions and roles as classes for type-safety and IDE support.
Permission Classes
php artisan mandate:permission UserPermissions --set=users
// app/Permissions/UserPermissions.php use OffloadProject\Mandate\Attributes\Description; use OffloadProject\Mandate\Attributes\Label; use OffloadProject\Mandate\Attributes\PermissionsSet; #[PermissionsSet('users')] final class UserPermissions { #[Label('View Users')] public const string VIEW = 'users.view'; #[Label('Create Users')] public const string CREATE = 'users.create'; #[Label('Update Users')] public const string UPDATE = 'users.update'; #[Label('Delete Users')] public const string DELETE = 'users.delete'; #[Label('Export Users'), Description('Export user data to CSV')] public const string EXPORT = 'users.export'; }
Role Classes
php artisan mandate:role SystemRoles --set=system
// app/Roles/SystemRoles.php use OffloadProject\Mandate\Attributes\Description; use OffloadProject\Mandate\Attributes\Inherits; use OffloadProject\Mandate\Attributes\Label; use OffloadProject\Mandate\Attributes\RoleSet; #[RoleSet('system')] final class SystemRoles { #[Label('Viewer')] public const string VIEWER = 'viewer'; #[Label('Editor')] #[Inherits(self::VIEWER)] // Editor inherits all Viewer permissions public const string EDITOR = 'editor'; #[Label('Administrator'), Description('Full system access')] #[Inherits(self::EDITOR)] // Admin inherits all Editor (and Viewer) permissions public const string ADMINISTRATOR = 'administrator'; }
Map Roles to Permissions (Config)
With inheritance defined in role classes, only specify direct permissions - inherited permissions resolve automatically:
// config/mandate.php use App\Permissions\UserPermissions; use App\Permissions\PostPermissions; use App\Roles\SystemRoles; 'role_permissions' => [ // Viewer gets base permissions SystemRoles::VIEWER => [ UserPermissions::VIEW, PostPermissions::VIEW, ], // Editor inherits Viewer permissions, only add Editor-specific SystemRoles::EDITOR => [ PostPermissions::CREATE, PostPermissions::UPDATE, ], // Administrator inherits Editor (and transitively Viewer) SystemRoles::ADMINISTRATOR => [ UserPermissions::class, // All user permissions PostPermissions::DELETE, ], ],
Feature Gates (Optional)
Features control which permissions/roles are available (requires Pennant or Hoist):
// app/Features/ExportFeature.php class ExportFeature { public string $name = 'export'; public string $label = 'Export Feature'; public function permissions(): array { return [ UserPermissions::EXPORT, PostPermissions::EXPORT, ]; } public function roles(): array { return [ // Roles gated by this feature ]; } public function resolve($user): bool { return $user->plan === 'enterprise'; } }
Sync to Database
# Initial setup - seeds role permissions from config php artisan mandate:sync --seed # Subsequent syncs - only adds new permissions/roles, preserves DB relationships php artisan mandate:sync
Usage
Type-Safe Permission Checks
use App\Permissions\UserPermissions; use App\Roles\SystemRoles; use OffloadProject\Mandate\Facades\Mandate; // Check permission (considers feature flags) if (Mandate::can($user, UserPermissions::EXPORT)) { // User has permission AND the export feature is enabled } // Check role (considers feature flags) if (Mandate::hasRole($user, SystemRoles::ADMINISTRATOR)) { // User has role AND any feature requirement is met } // Direct Spatie usage still works $user->hasPermissionTo(UserPermissions::VIEW); $user->hasRole(SystemRoles::EDITOR);
Middleware
Protect routes with feature-aware authorization:
use App\Permissions\UserPermissions; use App\Roles\SystemRoles; use OffloadProject\Mandate\Http\Middleware\MandatePermission; use OffloadProject\Mandate\Http\Middleware\MandateRole; // String-based (in routes) Route::get('/users/export', ExportController::class) ->middleware('mandate.permission:export users'); Route::get('/admin', AdminController::class) ->middleware('mandate.role:administrator'); Route::get('/premium', PremiumController::class) ->middleware('mandate.feature:App\Features\PremiumFeature'); // Multiple permissions/roles (OR logic) Route::get('/users', UserController::class) ->middleware('mandate.permission:view users,users.list'); // Type-safe with constants Route::get('/users/export', ExportController::class) ->middleware(MandatePermission::using(UserPermissions::EXPORT)); Route::get('/admin', AdminController::class) ->middleware(MandateRole::using(SystemRoles::ADMINISTRATOR, SystemRoles::EDITOR));
Available middleware:
mandate.permission:{permissions}- Check permission(s) with feature awarenessmandate.role:{roles}- Check role(s) with feature awarenessmandate.feature:{class}- Check if feature is active
Getting Data for UI
// All permissions (for admin UI) $permissions = Mandate::permissions()->all(); // Permissions grouped by set $grouped = Mandate::permissions()->grouped(); // Permissions for a user (with status) $userPermissions = Mandate::permissions()->forModel($user); // Returns: [{ name, label, set, active, featureActive, granted }, ...] // Only granted permissions (has + feature active) $granted = Mandate::grantedPermissions($user); // Only available permissions (feature is on) $available = Mandate::availablePermissions($user); // Same methods for roles $roles = Mandate::roles()->all(); $assigned = Mandate::assignedRoles($user); $available = Mandate::availableRoles($user);
Querying Features
// Get feature with its permissions and roles $feature = Mandate::feature(ExportFeature::class); $feature->permissions; // Permissions this feature gates $feature->roles; // Roles this feature gates // All features $features = Mandate::features()->all(); // Features for a user (with active status) $userFeatures = Mandate::features()->forModel($user);
Syncing to Database
By default, syncing only creates new permissions and roles without modifying existing role-permission relationships. This allows you to manage permissions via UI/database without config overwriting your changes.
// Sync (creates new permissions/roles, preserves existing relationships) Mandate::sync(); // Sync with seeding (resets role permissions to match config) Mandate::sync(seed: true); // Sync only permissions Mandate::syncPermissions(); // Sync only roles (without touching existing permissions) Mandate::syncRoles(); // Sync roles and seed permissions from config Mandate::syncRoles(seed: true); // Sync with specific guard Mandate::sync('api');
Configuration
// config/mandate.php return [ // Directories to scan for permission classes 'permission_directories' => [ app_path('Permissions') => 'App\\Permissions', ], // Directories to scan for role classes 'role_directories' => [ app_path('Roles') => 'App\\Roles', ], // Map roles to their permissions 'role_permissions' => [ // SystemRoles::ADMINISTRATOR => [UserPermissions::class], ], // Sync additional columns to database (requires migration) // Options: true (all), ['set', 'label'], or false (none) 'sync_columns' => false, // Auto-sync on boot (disable in production) 'auto_sync' => env('MANDATE_AUTO_SYNC', false), // TypeScript export path (null to require --output option) 'typescript_path' => resource_path('js/permissions.ts'), ];
Syncing Additional Columns
Optionally sync metadata from your permission and role classes to the database. This allows you to group and filter permissions/roles in your UI.
Available columns:
set- The set name from#[PermissionsSet]or#[RoleSet]label- The label from#[Label]attributedescription- The description from#[Description]attribute
Setup
-
Publish and run the migration:
php artisan vendor:publish --tag=mandate-migrations php artisan migrate
-
Enable in config:
// Sync all columns (set, label, description) 'sync_columns' => true, // Or sync specific columns only 'sync_columns' => ['set', 'label'],
-
Sync to populate the columns:
php artisan mandate:sync
Usage
Once enabled, columns will be:
- Populated when creating new permissions/roles
- Updated when running sync if values changed
- Available for querying in your application
// Query permissions by set $permissions = Permission::where('set', 'users')->get(); // Group in UI $grouped = Permission::all()->groupBy('set'); // Display labels in UI foreach ($permissions as $permission) { echo $permission->label ?? $permission->name; }
TypeScript Export
Generate a TypeScript file containing your permissions and roles as constants for type-safe frontend usage.
Generate TypeScript File
# Generate using configured path (default: resources/js/permissions.ts) php artisan mandate:typescript # Generate to a custom path php artisan mandate:typescript --output=resources/js/auth/permissions.ts
Output Format
The command generates a TypeScript file with your permissions, roles, features, and role hierarchy:
// This file is auto-generated by mandate:typescript. Do not edit manually. export const UserPermissions = { VIEW: "view users", CREATE: "create users", UPDATE: "update users", DELETE: "delete users", EXPORT: "export users", } as const; export const SystemRoles = { VIEWER: "viewer", EDITOR: "editor", ADMINISTRATOR: "administrator", } as const; export const Features = { ExportFeature: "export", PremiumFeature: "premium", } as const; export const RoleHierarchy = { "editor": { inheritsFrom: ["viewer"], permissions: ["edit posts"], inheritedPermissions: ["view posts"], }, "administrator": { inheritsFrom: ["editor"], permissions: ["delete posts", "manage users"], inheritedPermissions: ["view posts", "edit posts"], }, } as const; export type RoleWithHierarchy = keyof typeof RoleHierarchy;
Configuration
Configure the default output path in your config file:
// config/mandate.php 'typescript_path' => resource_path('js/permissions.ts'),
Frontend Usage
Use the generated constants for type-safe permission and feature checks:
import {UserPermissions, SystemRoles, Features, RoleHierarchy} from './permissions'; // Type-safe permission checking function canExport(userPermissions: string[]): boolean { return userPermissions.includes(UserPermissions.EXPORT); } // Type-safe feature checking function isFeatureEnabled(activeFeatures: string[]): boolean { return activeFeatures.includes(Features.ExportFeature); } // TypeScript will catch typos at compile time if (user.permissions.includes(UserPermissions.VIWE)) { // ❌ TypeScript error: Property 'VIWE' does not exist } // Create union types from permissions type UserPermission = typeof UserPermissions[keyof typeof UserPermissions]; // Result: "view users" | "create users" | "update users" | "delete users" | "export users" type FeatureName = typeof Features[keyof typeof Features]; // Result: "export" | "premium" // Use role hierarchy for UI display function getRolePermissions(role: string): string[] { const hierarchy = RoleHierarchy[role as keyof typeof RoleHierarchy]; if (!hierarchy) return []; return [...hierarchy.permissions, ...hierarchy.inheritedPermissions]; }
How It Works
The Authorization Flow
User wants to perform action requiring UserPermissions::EXPORT
│
▼
┌───────────────────────────────┐
│ Does user have permission? │──── No ────▶ Denied
│ (via Spatie) │
└───────────────────────────────┘
│
Yes
│
▼
┌───────────────────────────────┐
│ Is permission tied to a │
│ feature flag? │──── No ────▶ Granted
└───────────────────────────────┘
│
Yes
│
▼
┌───────────────────────────────┐
│ Is feature active for user? │──── No ────▶ Denied
│ (via Pennant) │
└───────────────────────────────┘
│
Yes
│
▼
Granted
Permission Status in UI
| Permission | Has Permission | Feature Active | Status |
|---|---|---|---|
| View Users | ✓ | N/A | ✅ Granted |
| Export Users | ✓ | ✗ | 🔒 Requires upgrade |
| Delete Users | ✗ | ✓ | ❌ Not assigned |
Role Hierarchy
Mandate supports role hierarchy with permission inheritance. Child roles automatically inherit all permissions from their parent roles.
Defining Hierarchy
Use the #[Inherits] attribute on role constants to define parent roles:
use OffloadProject\Mandate\Attributes\Inherits; use OffloadProject\Mandate\Attributes\Label; use OffloadProject\Mandate\Attributes\RoleSet; #[RoleSet('system')] final class SystemRoles { #[Label('Viewer')] public const string VIEWER = 'viewer'; #[Label('Editor')] #[Inherits(self::VIEWER)] public const string EDITOR = 'editor'; #[Label('Administrator')] #[Inherits(self::EDITOR)] public const string ADMINISTRATOR = 'administrator'; }
Multiple Inheritance
A role can inherit from multiple parent roles:
#[RoleSet('system')] final class SystemRoles { public const string CONTENT_MANAGER = 'content-manager'; public const string BILLING_ADMIN = 'billing-admin'; #[Label('Super Admin')] #[Inherits(self::CONTENT_MANAGER, self::BILLING_ADMIN)] public const string SUPER_ADMIN = 'super-admin'; }
How Inheritance Works
- Additive: Inherited permissions are combined with directly assigned permissions
- Transitive: If A inherits from B, and B inherits from C, then A gets permissions from both B and C
- Deduplicated: Duplicate permissions are automatically removed
- Circular Detection: Circular inheritance chains throw
CircularRoleInheritanceException
Querying Hierarchy
use OffloadProject\Mandate\Facades\Mandate; // Get a role's parent roles $parents = Mandate::roles()->parents('administrator'); // Get roles that inherit from a role $children = Mandate::roles()->children('viewer'); // Check all permissions (direct + inherited) $role = Mandate::roles()->find('administrator'); $allPermissions = $role->allPermissions(); // Direct + inherited $directOnly = $role->permissions; // Direct only $inheritedOnly = $role->inheritedPermissions; // Inherited only // Check if a permission is inherited $role->isInheritedPermission('view users'); // true if inherited, not direct
Database Sync with Hierarchy
When syncing roles to the database, inherited permissions are included:
# Sync roles - inherited permissions are synced to database
php artisan mandate:sync --seed
This means the database role will have all permissions (direct + inherited) assigned via Spatie.
Wildcard Permissions
Mandate supports wildcard patterns for permission matching, allowing flexible permission checks and role configuration.
Wildcard Patterns
The * wildcard matches a single segment (does not cross dots):
| Pattern | Matches | Does Not Match |
|---|---|---|
users.* |
users.view, users.create |
posts.view, users.admin.view |
*.view |
users.view, posts.view |
users.create, admin.users.view |
users.*.view |
users.admin.view, users.public.view |
users.view, posts.admin.view |
Using Wildcards in Permission Checks
Check if a user has any permission matching a pattern:
use OffloadProject\Mandate\Facades\Mandate; // Check if user has any users.* permission if (Mandate::can($user, 'users.*')) { // User has at least one permission like users.view, users.create, etc. } // Check if user has any *.view permission if (Mandate::can($user, '*.view')) { // User has at least one view permission (users.view, posts.view, etc.) }
Using Wildcards in Config
Assign multiple permissions to a role using wildcards:
// config/mandate.php 'role_permissions' => [ 'viewer' => [ '*.view', // All view permissions (users.view, posts.view, etc.) ], 'user-admin' => [ 'users.*', // All user permissions 'reports.view', // Plus specific permission ], 'super-admin' => [ UserPermissions::class, // All from class '*.delete', // Plus all delete permissions ], ],
Wildcards are expanded at sync time to the actual matching permissions.
Using Wildcards in Middleware
Protect routes with wildcard permission patterns:
use OffloadProject\Mandate\Http\Middleware\MandatePermission; // String-based Route::get('/users', UserController::class) ->middleware('mandate.permission:users.*'); Route::get('/reports', ReportController::class) ->middleware('mandate.permission:*.view'); // Using the helper Route::get('/users', UserController::class) ->middleware(MandatePermission::using('users.*'));
Dot-Notation Permissions
For best wildcard support, use dot-notation for permission names:
#[PermissionsSet('users')] final class UserPermissions { public const string VIEW = 'users.view'; public const string CREATE = 'users.create'; public const string UPDATE = 'users.update'; public const string DELETE = 'users.delete'; }
This naming convention enables powerful patterns:
users.*- All user permissions*.view- All view permissions across modules*.delete- All delete permissions (for admin roles)
Attributes
Permission Classes
| Attribute | Target | Description |
|---|---|---|
#[PermissionsSet('name')] |
Class | Groups permissions together (required) |
#[Label('Human Name')] |
Constant | Human-readable label |
#[Description('Details')] |
Constant | Detailed description |
#[Guard('web')] |
Class or Constant | Auth guard to use |
Role Classes
| Attribute | Target | Description |
|---|---|---|
#[RoleSet('name')] |
Class | Groups roles together (required) |
#[Label('Human Name')] |
Constant | Human-readable label |
#[Description('Details')] |
Constant | Detailed description |
#[Guard('web')] |
Class or Constant | Auth guard to use |
#[Inherits('parent', ...)] |
Constant | Parent role(s) to inherit permissions from |
Artisan Commands
# Create a permission class php artisan mandate:permission UserPermissions --set=users # Create a role class php artisan mandate:role SystemRoles --set=system # Sync permissions and roles to database php artisan mandate:sync # Creates new, preserves existing relationships php artisan mandate:sync --seed # Seeds role permissions from config (initial setup) php artisan mandate:sync --permissions # Only permissions php artisan mandate:sync --roles # Only roles php artisan mandate:sync --guard=api # Specific guard # Generate TypeScript file with permissions and roles php artisan mandate:typescript # Uses configured path php artisan mandate:typescript --output=custom.ts # Custom output path
Note: Use
--seedfor initial setup or when you intentionally want to reset role permissions to match config. Without--seed, the database is authoritative for role-permission relationships.
Events
Mandate dispatches events during sync operations, allowing you to hook into the sync lifecycle:
use OffloadProject\Mandate\Events\PermissionsSynced; use OffloadProject\Mandate\Events\RolesSynced; use OffloadProject\Mandate\Events\MandateSynced; // Listen to permission sync Event::listen(PermissionsSynced::class, function (PermissionsSynced $event) { Log::info('Permissions synced', [ 'created' => $event->created, 'existing' => $event->existing, 'updated' => $event->updated, 'guard' => $event->guard, ]); }); // Listen to role sync Event::listen(RolesSynced::class, function (RolesSynced $event) { Log::info('Roles synced', [ 'created' => $event->created, 'existing' => $event->existing, 'updated' => $event->updated, 'permissions_synced' => $event->permissionsSynced, 'seeded' => $event->seeded, ]); }); // Listen to full sync (both permissions and roles) Event::listen(MandateSynced::class, function (MandateSynced $event) { // $event->permissions - permission sync stats // $event->roles - role sync stats // $event->guard - guard used // $event->seeded - whether --seed was used });
Testing Your Application
Using Contracts for Mocking
Mandate provides contracts (interfaces) for all registries, making it easy to mock in tests:
use OffloadProject\Mandate\Contracts\PermissionRegistryContract; use OffloadProject\Mandate\Contracts\RoleRegistryContract; use OffloadProject\Mandate\Contracts\FeatureRegistryContract; // In your test public function test_something_with_permissions() { $mockRegistry = Mockery::mock(PermissionRegistryContract::class); $mockRegistry->shouldReceive('can')->with($user, 'view users')->andReturn(true); $this->app->instance(PermissionRegistryContract::class, $mockRegistry); // Your test... }
Testing
./vendor/bin/pest
License
The MIT License (MIT). Please see License File for more information.