offload-project / laravel-mandate
Unified authorization management for Laravel - roles, permissions, and feature flags in a type-safe API
Installs: 27
Dependents: 0
Suggesters: 0
Security: 0
Stars: 0
Watchers: 0
Forks: 0
Open Issues: 0
pkg:composer/offload-project/laravel-mandate
Requires
- php: ^8.4
- illuminate/contracts: ^11.0|^12.0
- illuminate/support: ^11.0|^12.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
- spatie/laravel-data: ^4.18
- spatie/laravel-permission: ^6.24.0
This package is auto-updated.
Last update: 2025-12-18 00:19:01 UTC
README
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, 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
- 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 - Testable: Contracts/interfaces for all registries enable easy mocking in tests
Requirements
- PHP 8.4+
- Laravel 11+
- Laravel Pennant 1.0+
- Laravel Hoist 1.0+
- Spatie Laravel Permission 6.0+
- Spatie Laravel Data 4.0+
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 php artisan vendor:publish --tag=mandate-migrations
Quick Start
1. Create 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'; }
2. Create Role Classes
php artisan mandate:role SystemRoles --set=system
// app/Roles/SystemRoles.php use OffloadProject\Mandate\Attributes\Description; use OffloadProject\Mandate\Attributes\Label; use OffloadProject\Mandate\Attributes\RoleSet; #[RoleSet('system')] final class SystemRoles { #[Label('Administrator'), Description('Full system access')] public const string ADMINISTRATOR = 'administrator'; #[Label('Editor')] public const string EDITOR = 'editor'; #[Label('Viewer')] public const string VIEWER = 'viewer'; }
3. Map Roles to Permissions (Config)
// config/mandate.php use App\Permissions\UserPermissions; use App\Permissions\PostPermissions; use App\Roles\SystemRoles; 'role_permissions' => [ SystemRoles::ADMINISTRATOR => [ UserPermissions::class, // All user permissions PostPermissions::class, // All post permissions ], SystemRoles::EDITOR => [ UserPermissions::VIEW, PostPermissions::VIEW, PostPermissions::CREATE, PostPermissions::UPDATE, ], SystemRoles::VIEWER => [ UserPermissions::VIEW, PostPermissions::VIEW, ], ],
4. Define Feature Gates (Optional)
Features control which permissions/roles are available:
// 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'; } }
5. 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:users.export'); 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:users.view,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), ];
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; }
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 |
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 |
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
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, 'users.view')->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.