rylxes / laravel-multitenancy
Simple by default, powerful when needed. Single-database and multi-database multitenancy for Laravel.
Requires
- php: ^8.1
- illuminate/console: ^10.0|^11.0|^12.0
- illuminate/database: ^10.0|^11.0|^12.0
- illuminate/events: ^10.0|^11.0|^12.0
- illuminate/http: ^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.0|^11.0
Suggests
- rylxes/laravel-tenant-jobs: First-class tenant-aware queue job handling (^1.0)
README
Simple, safe multi-tenancy for Laravel. Single-database by default, multi-database when you need it.
Why This Package?
| Pain Point | Solution |
|---|---|
| Data leakage between tenants | BelongsToTenant trait + safeguard mode throws on unscoped queries |
| Cache pollution | PrefixCacheTask auto-prefixes cache keys per tenant |
| Storage file mixing | PrefixFilesystemTask isolates disk paths per tenant |
| Queue jobs losing context | First-class integration with laravel-tenant-jobs |
| No fail-safe for missing scopes | Safeguard throws NoCurrentTenantException on unscoped queries |
| Migration complexity | tenant:migrate runs migrations across all tenants |
| Testing difficulty | ActingAsTenant trait for clean test setup |
| No CLI tenant management | 6 artisan commands out of the box |
Requirements
- PHP 8.1+
- Laravel 10, 11, or 12
Installation
composer require rylxes/laravel-multitenancy
Publish config and run migrations:
php artisan vendor:publish --tag=multitenancy-config php artisan migrate
Quick Start (5 Minutes)
1. Create a tenant
php artisan tenant:create "Acme Corp" --domain=acme.example.com
2. Add tenant_id to your models' tables
Schema::table('projects', function (Blueprint $table) { $table->foreignId('tenant_id')->constrained()->index(); });
3. Use the BelongsToTenant trait
use Illuminate\Database\Eloquent\Model; use Rylxes\Multitenancy\Concerns\BelongsToTenant; class Project extends Model { use BelongsToTenant; }
That's it. All queries on Project are now automatically scoped to the current tenant, and tenant_id is auto-filled on creation.
4. Add middleware to your routes
// routes/web.php or bootstrap/app.php Route::middleware('tenancy')->group(function () { Route::resource('projects', ProjectController::class); });
Tenant Identification
The package resolves tenants from incoming requests. Choose a strategy in config/multitenancy.php:
'tenant_finder' => \Rylxes\Multitenancy\Finders\DomainTenantFinder::class,
| Finder | How it works | Best for |
|---|---|---|
DomainTenantFinder |
Matches request->getHost() |
Custom domains (acme.com) |
SubdomainTenantFinder |
Extracts first subdomain part | SaaS subdomains (acme.app.com) |
PathTenantFinder |
Uses first URL segment | Path-based (app.com/acme/...) |
HeaderTenantFinder |
Reads X-Tenant-ID header |
APIs and SPAs |
Data Isolation
BelongsToTenant Trait
Add to any Eloquent model to auto-scope all queries and auto-fill tenant_id:
use Rylxes\Multitenancy\Concerns\BelongsToTenant; class Invoice extends Model { use BelongsToTenant; } // Automatically adds WHERE tenant_id = ? to all queries $invoices = Invoice::all(); // Only current tenant's invoices // tenant_id auto-filled on create $invoice = Invoice::create(['amount' => 100]); // tenant_id set automatically
Safeguard Mode
When safeguard is true (default), the package throws NoCurrentTenantException if you query a tenant-scoped model without an active tenant. This prevents accidental data leakage:
// Without any tenant context active: Invoice::all(); // Throws NoCurrentTenantException
Admin/Landlord Queries
Bypass tenant scoping when needed:
// Get all invoices across all tenants (admin use) $allInvoices = Invoice::withoutTenantScope()->get();
Cache & Storage Isolation
Enabled by default via switch tasks:
// config/multitenancy.php 'switch_tenant_tasks' => [ PrefixCacheTask::class, // Cache keys: tenant_1_key PrefixFilesystemTask::class, // Storage: storage/app/tenant_1/ ClearResolvedInstancesTask::class, // SwitchDatabaseTask::class, // Uncomment for multi-database ],
Multi-Database Support
Uncomment SwitchDatabaseTask in config and set your tenant's database field:
php artisan tenant:create "Acme Corp" --domain=acme.example.com --database=tenant_acme
// config/multitenancy.php 'database' => [ 'strategy' => 'multi', 'tenant_connection' => 'tenant', 'landlord_connection' => env('DB_CONNECTION', 'mysql'), ],
Programmatic Usage
use Rylxes\Multitenancy\Facades\Tenant; // Set current tenant $tenant = \Rylxes\Multitenancy\Models\Tenant::find(1); Tenant::makeCurrent($tenant); // Check current tenant Tenant::current(); // Returns Tenant model or null Tenant::check(); // Returns bool // Run code as a specific tenant Tenant::execute($tenant, function () { // All queries scoped to $tenant here $projects = Project::all(); }); // Previous tenant context (or none) is restored after // Global helper tenant(); // Returns current Tenant or null // Forget current tenant Tenant::forgetCurrent();
Artisan Commands
| Command | Description |
|---|---|
tenant:list |
List all tenants |
tenant:create {name} |
Create a tenant (--domain=, --database=) |
tenant:delete {id} |
Delete a tenant (with confirmation) |
tenant:migrate |
Run migrations for all tenants (--tenant= for one) |
tenant:seed |
Seed databases for all tenants |
tenant:artisan {command} |
Run any artisan command for all tenants |
Events
| Event | When |
|---|---|
TenantMadeCurrent |
After a tenant is set as current |
TenantForgotten |
After tenant context is cleared |
TenantCreated |
After a new tenant is created |
TenantDeleted |
After a tenant is deleted |
Queue Jobs
For tenant-aware queue jobs, install laravel-tenant-jobs:
composer require rylxes/laravel-tenant-jobs
Zero-config integration — laravel-tenant-jobs auto-detects this package and handles tenant context in all queued jobs, batch callbacks, retries, scheduled tasks, and notifications.
Testing
Use the ActingAsTenant trait in your tests:
use Rylxes\Multitenancy\Testing\ActingAsTenant; class ProjectTest extends TestCase { use ActingAsTenant; public function test_projects_are_scoped_to_tenant(): void { $tenant = Tenant::create(['name' => 'Test']); $this->actingAsTenant($tenant); $project = Project::create(['name' => 'My Project']); $this->assertEquals($tenant->id, $project->tenant_id); } }
Configuration Reference
// config/multitenancy.php return [ 'tenant_model' => \Rylxes\Multitenancy\Models\Tenant::class, 'tenant_finder' => \Rylxes\Multitenancy\Finders\DomainTenantFinder::class, 'tenant_column' => 'tenant_id', 'safeguard' => true, // Throw on unscoped tenant queries 'switch_tenant_tasks' => [ PrefixCacheTask::class, PrefixFilesystemTask::class, ClearResolvedInstancesTask::class, // SwitchDatabaseTask::class, // Uncomment for multi-DB ], 'database' => [ 'strategy' => 'single', // 'single' or 'multi' 'tenant_connection' => 'tenant', 'landlord_connection' => env('DB_CONNECTION', 'mysql'), ], 'cache' => ['prefix_base' => 'tenant_'], 'filesystem' => [ 'suffix_base' => 'tenant_', 'disks' => ['local', 'public'], ], ];
Architecture
src/
TenantManager.php — Core singleton: makeCurrent/forgetCurrent/execute
MultitenancyServiceProvider.php — Config, migrations, commands, middleware
Models/Tenant.php — Eloquent model
Facades/Tenant.php — Facade for TenantManager
Contracts/TenantFinder.php — Interface for identification strategies
Finders/ — Domain, Subdomain, Path, Header finders
Concerns/
BelongsToTenant.php — Trait: global scope + auto-fill tenant_id
TenantAware.php — Trait: iterate commands over tenants
Scopes/TenantScope.php — Global scope: WHERE tenant_id = ?
Tasks/ — Cache, Filesystem, Database, Instance switching
Middleware/ — InitializeTenancy, EnsureTenantSession
Events/ — TenantMadeCurrent, Forgotten, Created, Deleted
Console/Commands/ — 6 tenant management commands
Testing/ActingAsTenant.php — Test helper trait
License
MIT. See LICENSE.