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.

Maintainers

Package info

github.com/ubxty/multi-tenant-laravel-permissions

pkg:composer/ubxty/multi-tenant-laravel-permissions

Statistics

Installs: 8

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

2.0.0 2026-01-27 22:12 UTC

This package is auto-updated.

Last update: 2026-02-27 22:29:22 UTC


README

Latest Version on Packagist Total Downloads License PHP Version

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

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 TenantScoped trait
  • 👤 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:

  1. Filters all queries - Project::all() only returns projects for current tenant
  2. Sets tenant on create - Project::create([...]) auto-sets company_id
  3. 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:

  1. Database JSON Cache: Permissions and roles are cached as JSON in columns on the users table
  2. 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

  1. Replace imports:

    use App\Services\Tenancy\CompanyContext;
    →
    use Ubxty\MultiTenantLaravelPermissions\Tenancy\Services\TenantContext;
    
  2. 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.