spatie/laravel-multitenancy
An unopinionated Laravel package that makes your app tenant aware. It determines the current tenant per request, handles switching tenants, and supports single or multi-database setups, plus tenant-aware queued jobs and artisan commands.
The package is intentionally thin: it resolves a current tenant once per request and gives you hooks to swap infrastructure (DB, cache, filesystem, route cache) when that current tenant changes. It does not add query scopes or enforce isolation — that stays your job.
Install and scaffold:
composer require spatie/laravel-multitenancy
php artisan vendor:publish --provider="Spatie\Multitenancy\MultitenancyServiceProvider" --tag="multitenancy-config"
php artisan vendor:publish --provider="Spatie\Multitenancy\MultitenancyServiceProvider" --tag="multitenancy-migrations"
php artisan migrate --database=landlord
Then look at three files first, in this order:
config/multitenancy.php — tenant_model, tenant_finder, switch_tenant_tasks, current_tenant_container_key.app/Models/Tenant.php (or the shipped Spatie\Multitenancy\Models\Tenant) — a normal Eloquent model with name, domain, database.config/database.php — you need a landlord connection (where tenants live) and a tenant connection whose database is rewritten at runtime.First use case, domain-per-tenant with one database per tenant:
// config/database.php
'landlord' => [ /* central DB */ ],
'tenant' => [ 'database' => 'placeholder', /* same host/credentials */ ],
class Tenant extends \Spatie\Multitenancy\Models\Tenant
{
protected $connection = 'landlord';
}
class Order extends Model
{
use \Spatie\Multitenancy\Models\Concerns\UsesTenantConnection;
}
Register the task that actually switches the DB in switch_tenant_tasks:
'switch_tenant_tasks' => [
Spatie\Multitenancy\Tasks\SwitchTenantDatabaseTask::class,
Spatie\Multitenancy\Tasks\PrefixCacheTask::class,
],
Now acme.yourapp.test resolves to a tenant, and any model using UsesTenantConnection reads/writes the tenant connection pointed at that tenant's database. Verify quickly in Tinker:
Tenant::current(); // ?IsTenant
Tenant::checkCurrent(); // bool
app('currentTenant');
Resolve the tenant once, assert it where it matters. The configured TenantFinder runs automatically on every web request. Add Spatie\Multitenancy\Http\Middleware\NeedsTenant to route groups that must never fall through to landlord data — it throws TenantCouldNotBeDeterminedException and fires TenantNotFoundForRequestEvent, which is a clean place to return a branded 404.
Custom finders for non-domain strategies. Path prefixes, headers, API keys, and subdomain lookups against a separate domains table all go through one class:
class HeaderTenantFinder extends TenantFinder
{
public function findForRequest(Request $request): ?IsTenant
{
$key = $request->header('X-Tenant');
return $key ? Tenant::where('api_key', $key)->first() : null;
}
}
Point multitenancy.tenant_finder at it. Cache the lookup — this query otherwise runs on every single request, including asset-heavy page loads behind a load balancer.
Queues are the biggest daily win. Implement the interface on jobs that touch tenant data and the package serializes the tenant with the payload and makes it current before handle():
class RebuildReport implements ShouldQueue, TenantAware { /* ... */ }
Anything that must never inherit an ambient tenant (cleanup jobs, cross-tenant reporting) implements NotTenantAware. Note the v3 breaking change: non-tenant-aware jobs now actively forget the current tenant rather than inheriting it.
Console work — tenants:artisan fans a command out over every tenant:
php artisan tenants:artisan "migrate --path=database/migrations/tenant --force"
Scope it with the --tenants option (--help on your installed version for exact syntax). For scheduled work, iterate explicitly so failures are per-tenant and loggable:
Tenant::all()->eachCurrent(fn () => Artisan::call('reports:rebuild'));
TenantCollection also gives you filterCurrent() / rejectCurrent() for guard clauses.
Custom switch tasks are the extension point for anything global: filesystem roots, config overrides, search index prefixes, signed-URL salts.
class SwitchFilesystemTask implements SwitchTenantTask
{
public function makeCurrent(IsTenant $tenant): void { /* set disk root */ }
public function forgetCurrent(): void { /* restore */ }
}
Register it in switch_tenant_tasks. Implement both methods — the package calls forgetCurrent() on the old tenant before makeCurrent() on the new one.
Testing. Set the tenant in setUp, clear in tearDown:
protected function setUp(): void
{
parent::setUp();
$this->tenant = Tenant::factory()->create(['domain' => 'acme.localhost']);
$this->tenant->makeCurrent();
}
protected function tearDown(): void
{
$this->tenant->forgetCurrent();
parent::tearDown();
}
Hit tenant routes with $this->get('http://acme.localhost/orders') so the domain finder runs for real — don't bypass it in tests, or you'll miss resolution bugs.
UsesTenantConnection silently hits the landlord connection. Grep your models once at the start of the project and assert in a test that each tenant-scoped model resolves to the tenant connection.Tenant::current() is null in queue:work unless the job implements TenantAware. Symptom: jobs write to the landlord DB or throw "database placeholder not found" — that message means SwitchTenantDatabaseTask never ran.config/database.php; the task rewrites database.connections.tenant.database and calls DB::purge('tenant') for you.Config::set outside a task, URL::defaults) must be reset in a task's forgetCurrent() or on ForgettingCurrentTenantEvent. This is the number one source of cross-tenant leaks in production.switch_tenant_tasks matters — PrefixCacheTask must run after anything that reads config the cache depends on, and SwitchRouteCacheTask must exist if you cache routes per tenant (2.3.0+).failed() handling, cancel queued work on tenant deletion, and be aware that getKey() (not id) is used since 2.3.8, so UUID tenants work.database/migrations/landlord, database/migrations/tenant) and never let a tenant migration run on the landlord connection. tenants:artisan "migrate" runs against the current tenant connection only — landlord migrations are a plain php artisan migrate --database=landlord.php artisan tenants:list, and Illuminate\Support\Facades\Context::get('tenantId') — the package writes it there, so add it to your logging context once and every log line becomes tenant-attributable. Also Event::listen(MadeTenantCurrentEvent::class, ...) for tracing.multitenancy.tenant_model (must implement Spatie\Multitenancy\Contracts\IsTenant in v4), current_tenant_container_key if currentTenant collides, and the tenant_aware_jobs / not_tenant_aware_jobs config arrays (4.0.6+) when you can't add an interface to a vendor class.How can I help you explore Laravel packages today?