ubxty / multi-tenant-laravel-permissions
A powerful, multi-tenant capable permission handling package for Laravel with role-based access control, wildcard permissions, high-performance caching, and complete domain/subdomain-based tenancy support.
Package info
github.com/ubxty/multi-tenant-laravel-permissions
pkg:composer/ubxty/multi-tenant-laravel-permissions
Requires
- php: ^8.2
- illuminate/auth: ^10.0|^11.0|^12.0
- illuminate/cache: ^10.0|^11.0|^12.0
- illuminate/contracts: ^10.0|^11.0|^12.0
- illuminate/database: ^10.0|^11.0|^12.0
- illuminate/support: ^10.0|^11.0|^12.0
Requires (Dev)
- orchestra/testbench: ^8.0|^9.0|^10.0
- phpunit/phpunit: ^10.5|^11.0
README
A powerful, multi-tenant capable permission handling package for Laravel with role-based access control, wildcard permissions, high-performance caching, and complete domain/subdomain-based tenancy support.
Built by Ubxty
Table of Contents
- Features
- Requirements
- Installation
- Quick Start Guide
- Tenancy Deep Dive
- Permissions & Roles
- Caching
- Middleware
- Blade Directives
- Commands
- Upgrading / Migration
- Testing
Features
Permissions & Roles
- 🏢 Multi-Tenancy Support - Tenant-scoped roles and permissions out of the box
- 🔑 UUID Support - First-class UUID support for roles and permissions
- ⚡ High-Performance Caching - In-memory + database JSON caching for minimal DB queries
- 🃏 Wildcard Permissions - Grant access to permission groups with
posts.* - 🎛️ Permission Scopes - Attach JSON settings/constraints to permissions per role
- 🛡️ Middleware - Ready-to-use permission and role middleware
- 🎨 Blade Directives - Convenient
@role,@hasrole,@hasanyrole,@hasallroles,@unlessrole
Tenancy
- 🌐 Domain/Subdomain Resolution - Automatic tenant resolution from
tenant.yourapp.com - 🔒 Model Scoping - Automatic query filtering with
TenantScopedtrait - 👤 User-Based Resolution - Resolve tenant from authenticated user's
tenant_id - 🔄 Context Switching - Run code within specific tenant contexts
- 🛠️ Helper Functions -
tenant(),tenant_id(),has_tenant(),run_for_tenant() - 🎭 Facade Support -
Tenant::get(),Tenant::run(), etc. - 📦 Laravel 10, 11 & 12 - Full support for modern Laravel versions
Requirements
- PHP 8.2+
- Laravel 10.x, 11.x, or 12.x
Installation
Install the package via Composer:
composer require ubxty/multi-tenant-laravel-permissions
Publish the configuration and migrations:
php artisan vendor:publish --provider="Ubxty\MultiTenantLaravelPermissions\MultiTenantPermissionsServiceProvider"
Run the migrations:
php artisan migrate
Quick Start Guide
Tenancy Setup
Get multi-tenancy working in your app in 5 steps:
Step 1: Configure your tenant model
// config/multi-tenant-permissions.php 'tenancy' => [ 'tenant_model' => App\Models\Company::class, 'tenant_foreign_key' => 'company_id', 'subdomain_column' => 'subdomain', ],
Step 2: Create your Tenant model
// app/Models/Company.php namespace App\Models; use Illuminate\Database\Eloquent\Model; use Ubxty\MultiTenantLaravelPermissions\Tenancy\Contracts\Tenant; use Ubxty\MultiTenantLaravelPermissions\Tenancy\Traits\IsTenant; class Company extends Model implements Tenant { use IsTenant; protected $fillable = ['name', 'subdomain', 'email']; }
Step 3: Add middleware (Laravel 11+)
// bootstrap/app.php ->withMiddleware(function (Middleware $middleware) { $middleware->web(prepend: [ \Ubxty\MultiTenantLaravelPermissions\Tenancy\Middleware\SetTenantFromHost::class, ]); })
Step 4: Scope your models
// app/Models/Project.php use Ubxty\MultiTenantLaravelPermissions\Tenancy\Traits\TenantScoped; class Project extends Model { use TenantScoped; }
Step 5: Use helpers anywhere
$tenant = tenant(); // Get current tenant model $id = tenant_id(); // Get current tenant ID if (has_tenant()) { ... } // Check if tenant is set // Run code for a specific tenant run_for_tenant($tenant, function ($tenant) { return Project::all(); // Scoped to $tenant });
📚 Full Tenancy Documentation →
Permissions Setup
Step 1: Add HasRoles trait to User model
namespace App\Models; use Illuminate\Foundation\Auth\User as Authenticatable; use Ubxty\MultiTenantLaravelPermissions\Traits\HasRoles; class User extends Authenticatable { use HasRoles; }
Step 2: Create roles and permissions
use App\Models\Role; use App\Models\Permission; // Create permissions $permission = Permission::create(['name' => 'edit articles']); // Create roles (with optional tenant scoping) $role = Role::create([ 'name' => 'writer', 'title' => 'Content Writer', 'tenant_id' => tenant_id(), // Optional: scope to current tenant ]); // Assign permission to role $role->givePermissionTo($permission); // Assign role to user $user->assignRole('writer');
Tenancy Deep Dive
Tenancy Configuration Options
The tenancy section in your config file controls all tenant behavior:
// config/multi-tenant-permissions.php 'tenancy' => [ /* |-------------------------------------------------------------------------- | Tenant Model |-------------------------------------------------------------------------- | The Eloquent model that represents a tenant (Company, Organization, etc.) */ 'tenant_model' => App\Models\Company::class, /* |-------------------------------------------------------------------------- | Foreign Key |-------------------------------------------------------------------------- | The foreign key column used in tenant-scoped models | e.g., 'tenant_id', 'company_id', 'organization_id' */ 'tenant_foreign_key' => 'company_id', /* |-------------------------------------------------------------------------- | Identification Columns |-------------------------------------------------------------------------- | Columns used to identify tenants from URLs */ 'subdomain_column' => 'subdomain', // For subdomain.yourapp.com 'domain_column' => 'domain', // For full domain identification 'identifier_column' => 'subdomain', // Primary identifier /* |-------------------------------------------------------------------------- | Domain Settings |-------------------------------------------------------------------------- */ 'domain_identification' => false, // Enable full domain mode (tenant.com) 'central_domains' => [ // Domains that don't resolve a tenant 'yourapp.com', 'www.yourapp.com', 'localhost', ], 'ignored_subdomains' => ['www', 'api', 'app', 'admin'], /* |-------------------------------------------------------------------------- | Path Exclusions |-------------------------------------------------------------------------- | URL paths that skip tenant resolution (OAuth callbacks, webhooks) */ 'excluded_paths' => [ 'oauth/callback/*', 'webhooks/*', 'api/health', ], /* |-------------------------------------------------------------------------- | Security Settings |-------------------------------------------------------------------------- */ 'abort_without_tenant' => false, // 404 if no tenant on non-central domain 'no_tenant_message' => 'Organization not found', 'validate_user_tenant' => true, // Ensure user belongs to resolved tenant 'tenant_mismatch_action' => 'logout', // 'logout', 'abort', 'redirect' 'hide_data_without_tenant' => true, // Return empty results if no tenant /* |-------------------------------------------------------------------------- | Super Admin |-------------------------------------------------------------------------- */ 'super_admin_role' => 'super_admin', // Role that bypasses tenant restrictions /* |-------------------------------------------------------------------------- | URL Configuration |-------------------------------------------------------------------------- | Auto-configure app URL based on tenant subdomain */ 'configure_urls' => false, 'url_scheme' => 'https', 'base_domain' => env('APP_BASE_DOMAIN', 'yourapp.com'), /* |-------------------------------------------------------------------------- | Debug |-------------------------------------------------------------------------- */ 'debug_logging' => env('TENANCY_DEBUG', false), ],
Middleware Reference
| Middleware | Alias | Description | Use Case |
|---|---|---|---|
SetTenantFromHost |
tenant.host |
Resolves tenant from domain/subdomain | Web routes |
SetTenantFromUser |
tenant.user |
Resolves tenant from authenticated user | API routes |
EnsureTenant |
tenant.ensure |
Aborts 403 if no tenant resolved | Protected routes |
SkipTenantResolution |
tenant.skip |
Bypasses tenant resolution | OAuth, webhooks |
Laravel 11+ Setup (bootstrap/app.php)
use Illuminate\Foundation\Configuration\Middleware; return Application::configure(basePath: dirname(__DIR__)) ->withMiddleware(function (Middleware $middleware) { // Web: Resolve from subdomain $middleware->web(prepend: [ \Ubxty\MultiTenantLaravelPermissions\Tenancy\Middleware\SetTenantFromHost::class, ]); // API: Resolve from authenticated user $middleware->api(append: [ \Ubxty\MultiTenantLaravelPermissions\Tenancy\Middleware\SetTenantFromUser::class, ]); // Register aliases for route-level usage $middleware->alias([ 'tenant.host' => \Ubxty\MultiTenantLaravelPermissions\Tenancy\Middleware\SetTenantFromHost::class, 'tenant.user' => \Ubxty\MultiTenantLaravelPermissions\Tenancy\Middleware\SetTenantFromUser::class, 'tenant.ensure' => \Ubxty\MultiTenantLaravelPermissions\Tenancy\Middleware\EnsureTenant::class, 'tenant.skip' => \Ubxty\MultiTenantLaravelPermissions\Tenancy\Middleware\SkipTenantResolution::class, ]); });
Laravel 10 Setup (app/Http/Kernel.php)
protected $middlewareGroups = [ 'web' => [ \Ubxty\MultiTenantLaravelPermissions\Tenancy\Middleware\SetTenantFromHost::class, // ... other middleware ], 'api' => [ \Ubxty\MultiTenantLaravelPermissions\Tenancy\Middleware\SetTenantFromUser::class, // ... other middleware ], ]; protected $middlewareAliases = [ 'tenant.host' => \Ubxty\MultiTenantLaravelPermissions\Tenancy\Middleware\SetTenantFromHost::class, 'tenant.user' => \Ubxty\MultiTenantLaravelPermissions\Tenancy\Middleware\SetTenantFromUser::class, 'tenant.ensure' => \Ubxty\MultiTenantLaravelPermissions\Tenancy\Middleware\EnsureTenant::class, 'tenant.skip' => \Ubxty\MultiTenantLaravelPermissions\Tenancy\Middleware\SkipTenantResolution::class, ];
Route-Level Usage
// Require tenant context Route::middleware(['tenant.ensure'])->group(function () { Route::get('/dashboard', [DashboardController::class, 'index']); Route::resource('projects', ProjectController::class); }); // Skip tenant resolution (OAuth, webhooks) Route::middleware(['tenant.skip'])->group(function () { Route::get('/oauth/callback/{provider}', [OAuthController::class, 'callback']); Route::post('/webhooks/stripe', [StripeWebhookController::class, 'handle']); }); // Mix: require auth + tenant Route::middleware(['auth', 'tenant.ensure'])->group(function () { Route::get('/profile', [ProfileController::class, 'show']); });
Model Scoping
The TenantScoped Trait
Add to any model that should be filtered by tenant:
namespace App\Models; use Illuminate\Database\Eloquent\Model; use Ubxty\MultiTenantLaravelPermissions\Tenancy\Traits\TenantScoped; class Project extends Model { use TenantScoped; protected $fillable = ['name', 'description', 'company_id']; }
What it does automatically:
- Filters all queries -
Project::all()only returns projects for current tenant - Sets tenant on create -
Project::create([...])auto-setscompany_id - Hides data without tenant - If no tenant context, returns empty (security)
Bypassing the Scope
// Get all records across all tenants (admin panels, reports) $allProjects = Project::withoutTenantScope()->get(); // Query for a specific tenant $projects = Project::forTenant($tenantId)->get(); // Check if model belongs to current tenant if ($project->belongsToCurrentTenant()) { // ... }
Custom Foreign Key
class Project extends Model { use TenantScoped; // Override if different from config public function getTenantForeignKeyName(): string { return 'organization_id'; } }
Helper Functions & Facade
Helper Functions
Available globally after package installation:
// Get current tenant model (or null) $tenant = tenant(); // Get current tenant ID (or null) $id = tenant_id(); // Check if tenant is set if (has_tenant()) { // In tenant context } // Backward compatible alias (for migration from CompanyContext) $company = company(); // Run code for a specific tenant $result = run_for_tenant($tenant, function ($tenant) { // All queries here are scoped to $tenant return Project::count(); }); // Access TenantContext directly $context = tenancy();
Tenant Facade
use Ubxty\MultiTenantLaravelPermissions\Facades\Tenant; // Get current tenant $tenant = Tenant::get(); // Get tenant ID $id = Tenant::id(); // Check if tenant is set if (Tenant::check()) { ... } // Run for tenant Tenant::run($tenant, function ($tenant) { return Project::all(); }); // Register booting callback Tenant::booting(function ($tenant) { Log::info("Booting tenant: {$tenant->name}"); }); // Register ending callback Tenant::ending(function ($tenant) { Log::info("Ending tenant: {$tenant->name}"); });
Context Switching
Run code within a specific tenant's context:
// Using helper $projects = run_for_tenant($tenant, function ($tenant) { // All TenantScoped models query this tenant return Project::with('tasks')->get(); }); // Using facade $count = Tenant::run($tenant, fn($t) => Project::count()); // Using TenantContext directly use Ubxty\MultiTenantLaravelPermissions\Tenancy\Services\TenantContext; app(TenantContext::class)->run($tenant, function ($tenant) { // ... });
Note: Context switching is synchronous. The original tenant is restored after the callback.
Permissions & Roles
Creating & Managing Roles and Permissions
use App\Models\Role; use App\Models\Permission; // Create permissions Permission::create(['name' => 'view posts']); Permission::create(['name' => 'create posts']); Permission::create(['name' => 'edit posts']); Permission::create(['name' => 'delete posts']); // Create a role (optionally scoped to tenant) $role = Role::create([ 'name' => 'editor', 'title' => 'Content Editor', 'tenant_id' => tenant_id(), // null for global roles ]); // Assign permissions to role $role->givePermissionTo('view posts', 'create posts', 'edit posts'); // Or sync permissions $role->syncPermissions(['view posts', 'create posts']); // Remove a permission $role->revokePermissionTo('delete posts');
Assigning Roles to Users
$user = User::find(1); // Assign a single role $user->assignRole('editor'); // Assign multiple roles $user->assignRole('editor', 'writer'); // Sync roles (replaces all current roles) $user->syncRoles(['editor', 'admin']); // Remove a role $user->removeRole('writer');
Checking Permissions
// Check specific permission if ($user->hasPermissionTo('edit posts')) { ... } // Check via role if ($user->hasRole('admin')) { ... } // Check any role if ($user->hasAnyRole(['admin', 'editor'])) { ... } // Check all roles if ($user->hasAllRoles(['editor', 'writer'])) { ... } // Using Laravel's can() - works with Gate if ($user->can('edit posts')) { ... }
Tenant-Scoped Permissions
Check permissions for a specific tenant:
// Check if user has role for a specific tenant if ($user->hasRoleForTenant('admin', $tenantId)) { ... } // Assign a role for a specific tenant $user->assignRoleForTenant('manager', $tenantId); // Get all roles for a tenant $roles = $user->getRolesForTenant($tenantId); // Sync roles for a tenant $user->syncRolesForTenant(['editor', 'writer'], $tenantId); // Check permission for a tenant if ($user->hasPermissionForTenant('edit posts', $tenantId)) { ... }
Custom Models (Optional)
Extend the package models for custom logic:
// App\Models\Role.php namespace App\Models; use Ubxty\MultiTenantLaravelPermissions\Models\Role as BaseRole; class Role extends BaseRole { // Your custom logic }
Update your config to use custom models:
// config/multi-tenant-permissions.php 'models' => [ 'permission' => App\Models\Permission::class, 'role' => App\Models\Role::class, ],
Wildcard Permissions
Grant access to a group of permissions with wildcards:
// Create specific permissions Permission::create(['name' => 'posts.create']); Permission::create(['name' => 'posts.edit']); Permission::create(['name' => 'posts.delete']); Permission::create(['name' => 'posts.publish']); // Create wildcard permission $wildcard = Permission::create(['name' => 'posts.*']); // Assign wildcard - grants access to ALL posts.* permissions $role->givePermissionTo($wildcard); // Now this role has access to all posts permissions $user->can('posts.create'); // true $user->can('posts.edit'); // true $user->can('posts.delete'); // true $user->can('posts.publish'); // true // Multi-level wildcards Permission::create(['name' => 'admin.*']); // All admin permissions Permission::create(['name' => 'admin.users.*']); // All admin user permissions
Permission Settings & Scopes
Attach JSON settings to permissions when assigning to roles:
$role = Role::findByName('manager'); // Grant permission with specific scope settings $role->givePermissionToWithSettings('view_projects', [ 'scope' => 'team_only', 'max_count' => 10, 'regions' => ['us', 'eu'], ]); // Update settings $role->updatePermissionSettings('view_projects', [ 'scope' => 'global', 'max_count' => 100, ]); // Retrieve settings $settings = $role->getPermissionSettings('view_projects'); // ['scope' => 'global', 'max_count' => 100, 'regions' => ['us', 'eu']] // Use in application logic if ($user->hasPermissionTo('view_projects')) { $settings = $user->getPermissionSettings('view_projects'); if ($settings['scope'] === 'team_only') { $projects = Project::where('team_id', $user->team_id)->get(); } }
High-Performance Caching
This package implements a dual-layer caching system for optimal performance:
- Database JSON Cache: Permissions and roles are cached as JSON in columns on the users table
- In-Memory Cache: Parsed once per request, all subsequent checks use RAM
Setup
Add cache columns to your users table:
Schema::table('users', function (Blueprint $table) { $table->json('permissions_cache')->nullable(); $table->json('roles_cache')->nullable(); });
Performance Impact
| Scenario | Without Cache | With Cache |
|---|---|---|
| Dashboard with 20 permission checks | 20+ DB queries | 1 DB query |
| Kanban board with 50 items | 50+ DB queries | 1 DB query |
| API endpoint with multiple checks | N DB queries | 0 DB queries* |
*After first request loads cache into memory.
Cache Configuration
// config/multi-tenant-permissions.php 'cache' => [ 'expiration_time' => 60 * 24, // minutes 'key' => 'multi_tenant_permissions.cache', 'store' => 'default', ],
Cache Invalidation
Cache is automatically cleared when:
- Roles are assigned/removed from user
- Permissions are assigned/removed from role
- Role or permission is deleted
Manual cache clear:
php artisan permission:cache-reset
Middleware
Permission & Role Middleware
// Single role Route::get('/admin', [AdminController::class, 'index']) ->middleware('role:admin'); // Multiple roles (any) Route::get('/manage', [ManageController::class, 'index']) ->middleware('role:admin|manager'); // Single permission Route::post('/posts', [PostController::class, 'store']) ->middleware('permission:create posts'); // Multiple permissions (any) Route::put('/posts/{id}', [PostController::class, 'update']) ->middleware('permission:edit posts|manage posts'); // Role OR permission Route::delete('/posts/{id}', [PostController::class, 'destroy']) ->middleware('role_or_permission:admin|delete posts');
Registering Middleware
Laravel 11+ (bootstrap/app.php):
->withMiddleware(function (Middleware $middleware) { $middleware->alias([ 'role' => \Ubxty\MultiTenantLaravelPermissions\Middleware\RoleMiddleware::class, 'permission' => \Ubxty\MultiTenantLaravelPermissions\Middleware\PermissionMiddleware::class, 'role_or_permission' => \Ubxty\MultiTenantLaravelPermissions\Middleware\RoleOrPermissionMiddleware::class, ]); })
Laravel 10 (app/Http/Kernel.php):
protected $middlewareAliases = [ 'role' => \Ubxty\MultiTenantLaravelPermissions\Middleware\RoleMiddleware::class, 'permission' => \Ubxty\MultiTenantLaravelPermissions\Middleware\PermissionMiddleware::class, 'role_or_permission' => \Ubxty\MultiTenantLaravelPermissions\Middleware\RoleOrPermissionMiddleware::class, ];
Blade Directives
{{-- Check single role --}} @role('admin') <a href="/admin">Admin Panel</a> @endrole {{-- Check any role --}} @hasanyrole('admin|editor') <button>Edit Content</button> @endhasanyrole {{-- Check all roles --}} @hasallroles('editor|writer') <p>You can both write and edit!</p> @endhasallroles {{-- Negation --}} @unlessrole('admin') <p>You need admin access to see more options.</p> @endunlessrole {{-- Laravel's native @can works too --}} @can('edit posts') <button>Edit Post</button> @endcan @canany(['edit posts', 'delete posts']) <div class="post-actions">...</div> @endcanany
Commands
# Clear permission cache for all users php artisan permission:cache-reset # Show current permission cache status php artisan permission:show
Upgrading from Existing Tenancy
If you're migrating from a custom tenancy implementation (like CompanyContext), here's how:
Option 1: Use Aliases (Backward Compatible)
// app/Providers/AppServiceProvider.php public function register() { // Alias the package TenantContext as your old class $this->app->alias( \Ubxty\MultiTenantLaravelPermissions\Tenancy\Services\TenantContext::class, \App\Services\Tenancy\CompanyContext::class ); }
Option 2: Search & Replace
-
Replace imports:
use App\Services\Tenancy\CompanyContext; → use Ubxty\MultiTenantLaravelPermissions\Tenancy\Services\TenantContext; -
Replace method calls:
// Old CompanyContext::get(); CompanyContext::set($company); // New (facade) Tenant::get(); Tenant::set($company); // Or helpers tenant();
Option 3: Use Compatibility Helper
The package includes a company() helper for backward compatibility:
// These are equivalent: $company = company(); $company = tenant();
Configuration
The full configuration file allows customization of:
- Models: Custom Role and Permission classes
- Table Names: Custom database table names
- Column Names: Custom pivot keys, tenant column, cache columns
- Cache Settings: Expiration time, cache key, cache store
- Tenancy: Full tenancy configuration (see Tenancy Configuration)
See the published config/multi-tenant-permissions.php for all options.
Testing
composer test
Changelog
Please see CHANGELOG for more information on what has changed recently.
Contributing
Please see CONTRIBUTING for details.
Security
If you discover any security related issues, please email info@ubxty.com or open an issue on GitHub.
Credits
License
The MIT License (MIT). Please see License File for more information.