stancl/tenancy
Automatic multi-tenancy for Laravel with no code changes needed. Database-per-tenant, hostname and subdomain identification, and no model traits or class swapping. Docs at tenancyforlaravel.com.
Install & scaffold
composer require stancl/tenancy
php artisan tenancy:install
php artisan migrate # central migrations: tenants, domains
tenancy:install publishes config/tenancy.php, routes/tenant.php, central/tenant migration folders, App\Models\Tenant + App\Models\Domain, and App\Providers\TenancyServiceProvider (the event → listener map you'll grow during provisioning).
Read these three files first, in this order
config/tenancy.php — tenant_model, domain_model, central_domains, bootstrappers, migration_parameters, database managers, features.routes/tenant.php — the middleware stack that decides how a tenant is identified.app/Models/Tenant.php — the only model you'll touch constantly.First use case: one database per customer
use App\Models\Tenant;
$tenant = Tenant::create(['id' => 'acme']); // id is the tenant key
$tenant->domains()->create(['domain' => 'acme.localhost']);
// shell
php artisan tenants:migrate # runs database/migrations/tenant on every tenant
php artisan tenants:seed
Then hit http://acme.localhost:8000 with the default middleware stack:
// routes/tenant.php
Route::middleware([
'web',
InitializeTenancyByDomain::class,
PreventAccessFromCentralDomains::class,
])->group(function () {
Route::get('/', fn () => 'Tenant: ' . tenant('id'));
});
If that returns Tenant: acme, you're done with setup — everything below is where the real work is.
The connection contract. Add a tenant connection to config/database.php whose credentials are placeholders; the DatabaseTenancyBootstrapper rewrites it from tenancy.database.template_tenant_connection + the tenant's data.tenancy_db_name. Tenant models just say protected $connection = 'tenant'; — no traits, no per-model connection gymnastics, which is the package's core selling point.
Three ways to move between contexts — pick deliberately, they are not interchangeable:
tenancy()->initialize($tenant); // imperative switch (commands, seeders, tests)
// ... tenant-scoped work ...
tenancy()->end();
$tenant->run(function () { // scoped switch, auto-reverts
User::count();
});
tenant(); // null in central context — guard your code
tenant('id');
tenancy()->central(fn () => User::count()); // reach back from tenant context
Rule of thumb: HTTP requests get context from middleware, everything else (artisan, jobs, tests, scheduled closures) uses initialize() / run().
Identification strategies — all in Stancl\Tenancy\Middleware\*, and choosing wrong is the #1 source of "why is my tenant data on the central domain":
| Middleware | Use when |
|---|---|
InitializeTenancyByDomain |
one custom domain per tenant |
InitializeTenancyBySubdomain |
acme.app.com, with reserved subdomain list in config |
InitializeTenancyByDomainOrSubdomain |
mixed (custom domain or fallback subdomain) |
InitializeTenancyByPath |
/acme/... routes; pass the parameter name |
InitializeTenancyByRequestData |
API keys: reads header/query/cookie |
Always pair with PreventAccessFromCentralDomains on tenant routes, and use ScopeSessions when tenants share a cookie domain.
Provisioning as an event chain. Tenant::create() fires TenantCreated; the database manager creates the DB (MySQL/PostgreSQL/SQLite/MariaDB managers, selected in tenancy.database). Wire the rest in TenancyServiceProvider:
Events\TenantCreated::class => [
JobPipeline::make([
Jobs\CreateDatabase::class,
Jobs\MigrateDatabase::class,
Jobs\SeedDatabase::class,
// your own:
CreateDefaultAdmin::class,
])->send(function (Events\TenantCreated $event) {
return $event->tenant;
})->shouldBeQueued(false),
],
Keep the pipeline synchronous for the first version; move to queued jobs once provisioning gets slow, and make every step idempotent (re-provisioning a failed tenant must work).
Queues. With QueueTenancyBootstrapper enabled, jobs dispatched inside tenant context carry the tenant key and the worker re-initializes the tenant before handle(). Since v3.5 you can inject full Eloquent models, not just IDs. Dispatch from tenant context, not from central code looping tenants, unless you explicitly $tenant->run(fn () => MyJob::dispatch()).
Scheduler. Central schedule is central. Loop tenants explicitly:
$schedule->call(function () {
Tenant::cursor()->each(fn ($tenant) => $tenant->run(
fn () => Artisan::call('reports:daily')
));
})->daily();
Or tenants:artisan "reports:daily" inside a scheduled command — it handles the iteration and context for you.
Per-tenant config. Enable the TenantConfig feature in tenancy.features, store overrides in tenant data, and nested keys (e.g. mail.from.address) are merged on initialize():
$tenant->set('config', ['app.locale' => 'de', 'mail.from.address' => '[email protected]']);
This is the cleanest way to handle locale, mail senders, and feature flags without a settings table.
Testing. Create a tenant per test (not per suite — tenant state leaks), and migrate only that tenant:
$tenant = Tenant::create(['id' => 'test']);
$this->artisan('tenants:migrate', ['--tenants' => [$tenant->getTenantKey()]]);
$this->app->instance('tenant', $tenant); // or tenancy()->initialize($tenant)
Use RefreshDatabase for central tables only; tenant migrations are a separate concern and can't be handled by Laravel's trait.
CacheTenancyBootstrapper requires a taggable store. file and database cache drivers will throw when tenancy initializes. Use Redis or Memcached, or drop the bootstrapper and namespace keys yourself.tenants:* vs tenancy:*. The trailing s is the whole thing: tenants:migrate / tenants:artisan loop all tenants (--tenants=id1,id2 to target), tenancy:run runs against the current tenant. Mixing these up is a classic "I migrated only one tenant" incident.Tenant row does not drop the database. Hook TenantDeleted or call $tenant->database()->manager()->deleteDatabase($tenant). Orphan databases accumulate and are billed by your provider.data JSON column hides attributes. tenancy_db_name, config, etc. live in data unless you add real columns and declare them via $customColumns. Filtering requires Tenant::where('data->tenancy_db_name', ...) — JSON path syntax, so MySQL vs PostgreSQL vs SQLite all behave slightly differently.Tenant::create(['id' => 'acme']) → database tenantacme (configurable prefix) and storage/tenantacme. Enforce a slug-ish validation rule, and remember PostgreSQL's 63-byte identifier limit — long UUIDs plus a prefix will truncate.storage_path() and (if asset_helper_tenancy is on) asset(). Per-tenant public disks need their own storage:link-equivalent; use tenancy()->asset() / the ViteBundler feature for Vite builds, and remember php artisan optimize caches are per-tenant too.SESSION_DOMAIN=.localhost means one tenant's session is readable by another. Add ScopeSessions and decide consciously between shared SSO and isolated sessions.route:cache breaks closure routes. routes/tenant.php full of closures won't cache. Convert to invokable controllers before production deploys with route caching enabled.revert() symmetrically or queue workers and Octane will bleed tenant config into the next request/job. Related: don't cache tenanted Eloquent models or services in singletons/static properties.tenancy()->initialized, tenancy()->tenant?->getTenantKey(), config('database.connections.tenant.database'), and whether tenancy.central_domains contains the exact host you're hitting (otherwise PreventAccessFromCentralDomains 404s you on localhost vs 127.0.0.1).*.localhost resolves on modern macOS/Linux/Chrome; for artisan serve use --host=acme.localhost or add hosts entries. 127.0.0.1 is a different tenant identity than localhost unless both are in central_domains.TenancyBootstrapper for anything global-scoped (search clients, feature flags); implement TenantDatabaseManager for exotic DBs; swap tenant_model/domain_model in config for custom behavior; tenancy.features is a clean registry for opt-in behaviors (UniversalRoutes, UserImpersonation, Telescope, CrossDomainRedirect, ViteBundler) instead of fork-patching.v4.tenancyforlaravel.com — read the docs for your installed major, not the README's link, before copying snippets.How can I help you explore Laravel packages today?