stancl/tenancy
Automatic multi-tenancy for Laravel apps with minimal code changes. Provides tenant identification (e.g., by hostname/subdomains), isolated tenant bootstrapping, and tenancy-aware database/config switching without swapping core Laravel classes or adding model traits.
Installation:
composer require stancl/tenancy
php artisan vendor:publish --provider="Stancl\Tenancy\TenancyServiceProvider"
This publishes the tenancy.php config and migrations.
Run migrations:
php artisan migrate
This creates the tenants table and any required database structures.
First Tenant Creation:
php artisan tenant:create your-tenant-name --domain=tenant.example.com
This creates a tenant with a subdomain and initializes its database.
Test Tenant Access:
Configure your Hosts file to point tenant.example.com to localhost.
Visit https://tenant.example.com—the app should automatically load the tenant’s context.
// In routes/web.php
Route::get('/', function () {
// Automatically resolves tenant based on hostname
return "Welcome to " . Tenant::find()->domain;
});
tenant.example.com).TenantResolver for alternative logic (e.g., path-based, header-based).
// app/Providers/TenantServiceProvider.php
public function boot()
{
Tenant::resolver(function ($request) {
return Tenant::where('identifier', $request->header('X-Tenant-ID'))->first();
});
}
// app/Http/Kernel.php
protected $middlewareGroups = [
'web' => [
\Stancl\Tenancy\Middleware\InitializeTenancyByDomain::class,
// Other middleware...
],
];
Route::universal() for routes that should bypass tenant resolution (e.g., admin panel).
Route::universal('/admin', function () {
// No tenant context
});
$posts = Post::all(); // Queries tenant's database
CentralDatabase facade for shared tables (e.g., tenants).
use Stancl\Tenancy\Database\CentralDatabase;
$tenant = CentralDatabase::table('tenants')->where('domain', 'tenant.example.com')->first();
// In a controller
SendWelcomeEmail::dispatch($user); // Runs in tenant context
// In the job
public function handle()
{
$this->user->sendEmail(); // Uses tenant's database
}
QueueTenancyBootstrapper for testing.
$this->artisan('queue:work', [
'--once' => true,
'--tenant' => 'tenant-id',
]);
tenant() helper or TenantStorage facade.
use Stancl\Tenancy\Facades\TenantStorage;
$path = TenantStorage::disk('public')->path('file.txt');
// vite.config.js
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import tenancy from 'laravel-tenancy/vite';
export default defineConfig({
plugins: [
laravel({
input: ['resources/sass/app.scss'],
refresh: true,
}),
tenancy({
assetPathResolver: (tenant) => `/assets/${tenant->identifier}/`,
}),
],
});
php artisan tenant:migrate your-tenant-name
--central flag for shared tables.
php artisan db:seed --class=CentralDatabaseSeeder --central
Tenant::impersonate($tenantId, function () {
// Code runs in the context of $tenantId
$posts = Post::all();
});
$tenant->searchable()->addIndex('posts');
$user->newSubscription('monthly', $plan)->create($stripeToken);
Notification::route('mail', $user->email)->notify(new WelcomeNotification());
public function testAsTenant()
{
Tenant::actingAs($tenantId, function () {
$response = $this->get('/');
$response->assertSee('Tenant Content');
});
}
public function testCentralDatabase()
{
Tenant::actingAsCentral(function () {
$tenant = Tenant::create(['domain' => 'test.example.com']);
$this->assertDatabaseHas('tenants', ['domain' => 'test.example.com']);
});
}
// tenancy.php
'resolver_cache' => true,
'resolver_cache_ttl' => 60, // Cache for 60 seconds
pgsql or mysql with connection pooling (e.g., pgbouncer).// ❌ Avoid this in jobs
public function handle()
{
$user = User::find($this->userId); // May fail if tenant context isn't set
}
find() or first() to ensure the tenant context is resolved.
// ✅ Correct approach
public function handle()
{
$user = User::where('id', $this->userId)->first();
}
InitializeTenancyByDomain after authentication middleware can cause tenant resolution to fail if the user isn’t authenticated.InitializeTenancyByDomain is the first middleware in the web group.assetPathResolver isn’t configured.tenancy() Vite plugin and ensure the resolver returns the correct path.
// vite.config.js
tenancy({
assetPathResolver: (tenant) => `/build/${tenant.identifier}/`,
}),
CentralDatabase for shared tables can lead to SQLSTATE[HY000]: General error.CentralDatabase::.
// ❌ Wrong
$tenants = Tenant::all(); // Uses tenant's database
// ✅ Correct
$tenants = CentralDatabase::table('tenants')->get();
php artisan db:seed in production may overwrite tenant data.--force carefully and prefer --central for shared tables.
php artisan db:seed --class=CentralDatabaseSeeder --central --force
tenant:delete command and ensure all related data is cleaned up.
php artisan tenant:delete your-tenant-name --force
Route::universal() for paths that shouldn’t resolve tenants.tenant() helper to inspect the current tenant.
dd(Tenant::find()); // Debug current tenant
How can I help you explore Laravel packages today?