|
| 1 | +--- |
| 2 | +name: laravel-multitenancy-development |
| 3 | +description: Build and work with Spatie Laravel Multitenancy features, including tenant finders, the current tenant, switch tasks, multi-database setups, tenant-aware queues and artisan commands. |
| 4 | +--- |
| 5 | + |
| 6 | +# Laravel Multitenancy Development |
| 7 | + |
| 8 | +## When to use this skill |
| 9 | + |
| 10 | +Use this skill when working with multi-tenant Laravel applications using `spatie/laravel-multitenancy`: determining the current tenant per request, isolating databases or caches per tenant, making queued jobs and artisan commands tenant-aware, or designing landlord/tenant migration strategies. |
| 11 | + |
| 12 | +## Core Concepts |
| 13 | + |
| 14 | +- **Intentionally minimal**: the package resolves a current tenant and runs tasks on switch — it does not add global query scopes or model isolation by itself. |
| 15 | +- **Current tenant** is bound in the IoC container under the key `currentTenant` and written to Laravel `Context` under the key `tenantId`. |
| 16 | +- A **`TenantFinder`** resolves the tenant from the current HTTP request (e.g. by domain). |
| 17 | +- **`SwitchTenantTask`** classes mutate the environment when a tenant becomes current (switch DB, prefix cache, etc.) and restore it when forgotten. |
| 18 | +- Models on the landlord DB use `UsesLandlordConnection`; models on the tenant DB use `UsesTenantConnection`. |
| 19 | + |
| 20 | +## Setup |
| 21 | + |
| 22 | +```bash |
| 23 | +composer require spatie/laravel-multitenancy |
| 24 | +php artisan vendor:publish --provider="Spatie\Multitenancy\MultitenancyServiceProvider" --tag="multitenancy-config" |
| 25 | +php artisan vendor:publish --provider="Spatie\Multitenancy\MultitenancyServiceProvider" --tag="multitenancy-migrations" |
| 26 | +``` |
| 27 | + |
| 28 | +Register middleware in `bootstrap/app.php`: |
| 29 | + |
| 30 | +```php |
| 31 | +->withMiddleware(function (Middleware $middleware) { |
| 32 | + $middleware->web(append: [ |
| 33 | + \Spatie\Multitenancy\Http\Middleware\NeedsTenant::class, |
| 34 | + \Spatie\Multitenancy\Http\Middleware\EnsureValidTenantSession::class, |
| 35 | + ]); |
| 36 | +}) |
| 37 | +``` |
| 38 | + |
| 39 | +## Configuring a Tenant Finder |
| 40 | + |
| 41 | +Set the finder class in `config/multitenancy.php`: |
| 42 | + |
| 43 | +```php |
| 44 | +'tenant_finder' => \Spatie\Multitenancy\TenantFinder\DomainTenantFinder::class, |
| 45 | +``` |
| 46 | + |
| 47 | +`DomainTenantFinder` looks up the tenant by matching `$request->getHost()` against a `domain` column on the tenants table. |
| 48 | + |
| 49 | +To use a custom finder, extend `TenantFinder` and implement `findForRequest`: |
| 50 | + |
| 51 | +```php |
| 52 | +use Illuminate\Http\Request; |
| 53 | +use Spatie\Multitenancy\Contracts\IsTenant; |
| 54 | +use Spatie\Multitenancy\TenantFinder\TenantFinder; |
| 55 | + |
| 56 | +class SubdomainTenantFinder extends TenantFinder |
| 57 | +{ |
| 58 | + public function findForRequest(Request $request): ?IsTenant |
| 59 | + { |
| 60 | + $subdomain = explode('.', $request->getHost())[0]; |
| 61 | + |
| 62 | + return app(IsTenant::class)::whereSubdomain($subdomain)->first(); |
| 63 | + } |
| 64 | +} |
| 65 | +``` |
| 66 | + |
| 67 | +## Working with the Current Tenant |
| 68 | + |
| 69 | +```php |
| 70 | +use Spatie\Multitenancy\Models\Tenant; |
| 71 | + |
| 72 | +// Make a tenant current (fires events, runs tasks) |
| 73 | +$tenant->makeCurrent(); |
| 74 | + |
| 75 | +// Read the current tenant |
| 76 | +Tenant::current(); // returns ?Tenant |
| 77 | +app('currentTenant'); // same, via container |
| 78 | + |
| 79 | +// Check and forget |
| 80 | +Tenant::checkCurrent(); // bool |
| 81 | +$tenant->isCurrent(); // bool |
| 82 | +Tenant::forgetCurrent(); // runs forget tasks, returns the tenant |
| 83 | +``` |
| 84 | + |
| 85 | +## Executing Code for a Tenant or Landlord |
| 86 | + |
| 87 | +`execute()` makes the tenant current, runs the callable, then restores the previous state: |
| 88 | + |
| 89 | +```php |
| 90 | +$result = $tenant->execute(function (Tenant $tenant) { |
| 91 | + return cache()->get('stats'); |
| 92 | +}); |
| 93 | +``` |
| 94 | + |
| 95 | +`callback()` returns a closure — useful for the scheduler: |
| 96 | + |
| 97 | +```php |
| 98 | +$schedule->call($tenant->callback(fn () => cache()->flush()))->daily(); |
| 99 | +``` |
| 100 | + |
| 101 | +To run code **outside** any tenant context, use `Landlord`: |
| 102 | + |
| 103 | +```php |
| 104 | +use Spatie\Multitenancy\Landlord; |
| 105 | + |
| 106 | +Landlord::execute(function () { |
| 107 | + Artisan::call('cache:clear'); |
| 108 | +}); |
| 109 | +``` |
| 110 | + |
| 111 | +`TenantCollection` adds iteration helpers: `eachCurrent`, `mapCurrent`, `filterCurrent`, `rejectCurrent`. |
| 112 | + |
| 113 | +```php |
| 114 | +Tenant::all()->eachCurrent(function (Tenant $tenant) { |
| 115 | + cache()->flush(); |
| 116 | +}); |
| 117 | +``` |
| 118 | + |
| 119 | +## Multi-Database Setup |
| 120 | + |
| 121 | +Define a `tenant` connection (with `database => null`) and a `landlord` connection in `config/database.php`: |
| 122 | + |
| 123 | +```php |
| 124 | +'connections' => [ |
| 125 | + 'tenant' => [ |
| 126 | + 'driver' => 'mysql', |
| 127 | + 'database' => null, |
| 128 | + 'host' => '127.0.0.1', |
| 129 | + 'username' => 'root', |
| 130 | + 'password' => '', |
| 131 | + ], |
| 132 | + |
| 133 | + 'landlord' => [ |
| 134 | + 'driver' => 'mysql', |
| 135 | + 'database' => 'name_of_landlord_db', |
| 136 | + 'host' => '127.0.0.1', |
| 137 | + 'username' => 'root', |
| 138 | + 'password' => '', |
| 139 | + ], |
| 140 | +], |
| 141 | +``` |
| 142 | + |
| 143 | +Set the connection names in `config/multitenancy.php`: |
| 144 | + |
| 145 | +```php |
| 146 | +'tenant_database_connection_name' => 'tenant', |
| 147 | +'landlord_database_connection_name' => 'landlord', |
| 148 | +``` |
| 149 | + |
| 150 | +Apply the correct connection trait to every Eloquent model: |
| 151 | + |
| 152 | +```php |
| 153 | +// Models whose table lives in the tenant DB |
| 154 | +use Spatie\Multitenancy\Models\Concerns\UsesTenantConnection; |
| 155 | + |
| 156 | +class Post extends Model |
| 157 | +{ |
| 158 | + use UsesTenantConnection; |
| 159 | +} |
| 160 | + |
| 161 | +// Models whose table lives in the landlord DB |
| 162 | +use Spatie\Multitenancy\Models\Concerns\UsesLandlordConnection; |
| 163 | + |
| 164 | +class Tenant extends Model |
| 165 | +{ |
| 166 | + use UsesLandlordConnection; |
| 167 | +} |
| 168 | +``` |
| 169 | + |
| 170 | +## Switch Tenant Tasks |
| 171 | + |
| 172 | +Tasks run every time `makeCurrent()` or `forgetCurrent()` is called. Register them in `config/multitenancy.php`: |
| 173 | + |
| 174 | +```php |
| 175 | +'switch_tenant_tasks' => [ |
| 176 | + \Spatie\Multitenancy\Tasks\SwitchTenantDatabaseTask::class, |
| 177 | + // \Spatie\Multitenancy\Tasks\PrefixCacheTask::class, |
| 178 | + // \Spatie\Multitenancy\Tasks\SwitchRouteCacheTask::class, |
| 179 | +], |
| 180 | +``` |
| 181 | + |
| 182 | +Built-in tasks: |
| 183 | + |
| 184 | +- **`SwitchTenantDatabaseTask`** — sets the `tenant` connection's `database` to `$tenant->database` and purges the connection. Required for multi-DB. |
| 185 | +- **`PrefixCacheTask`** — overrides `cache.prefix` to `tenant_{$tenant->id}`. Works with memory-based stores (Redis, APC). |
| 186 | +- **`SwitchRouteCacheTask`** — switches `APP_ROUTES_CACHE` to a per-tenant file (`bootstrap/cache/routes-v7-tenant-{id}.php`), or a shared file when `'shared_routes_cache' => true`. |
| 187 | + |
| 188 | +To create a custom task, implement `SwitchTenantTask`: |
| 189 | + |
| 190 | +```php |
| 191 | +use Spatie\Multitenancy\Contracts\IsTenant; |
| 192 | +use Spatie\Multitenancy\Tasks\SwitchTenantTask; |
| 193 | + |
| 194 | +class SwitchStorageDiskTask implements SwitchTenantTask |
| 195 | +{ |
| 196 | + public function makeCurrent(IsTenant $tenant): void |
| 197 | + { |
| 198 | + config(['filesystems.disks.s3.bucket' => $tenant->bucket]); |
| 199 | + } |
| 200 | + |
| 201 | + public function forgetCurrent(): void |
| 202 | + { |
| 203 | + config(['filesystems.disks.s3.bucket' => config('filesystems.default_bucket')]); |
| 204 | + } |
| 205 | +} |
| 206 | +``` |
| 207 | + |
| 208 | +Tasks can receive constructor parameters via array config: |
| 209 | + |
| 210 | +```php |
| 211 | +'switch_tenant_tasks' => [ |
| 212 | + \App\Tasks\YourTask::class => ['key' => 'value'], |
| 213 | +], |
| 214 | +``` |
| 215 | + |
| 216 | +## Middleware |
| 217 | + |
| 218 | +- **`NeedsTenant`** — aborts the request (throws `NoCurrentTenant`) if no tenant is current. Apply to all tenant routes. |
| 219 | +- **`EnsureValidTenantSession`** — stores the first-seen tenant ID in the session and aborts with 401 if a different tenant ID is detected later. Prevents session cross-contamination. |
| 220 | + |
| 221 | +## Custom Tenant Model |
| 222 | + |
| 223 | +Set `tenant_model` in `config/multitenancy.php` and point it to your own class: |
| 224 | + |
| 225 | +```php |
| 226 | +'tenant_model' => \App\Models\Tenant::class, |
| 227 | +``` |
| 228 | + |
| 229 | +To use an existing model (e.g. a Jetstream `Team`) as a tenant, implement `IsTenant` with the `ImplementsTenant` trait: |
| 230 | + |
| 231 | +```php |
| 232 | +use Spatie\Multitenancy\Contracts\IsTenant; |
| 233 | +use Spatie\Multitenancy\Models\Concerns\ImplementsTenant; |
| 234 | +use Spatie\Multitenancy\Models\Concerns\UsesLandlordConnection; |
| 235 | + |
| 236 | +class Team extends JetstreamTeam implements IsTenant |
| 237 | +{ |
| 238 | + use UsesLandlordConnection; |
| 239 | + use ImplementsTenant; |
| 240 | +} |
| 241 | +``` |
| 242 | + |
| 243 | +Use a `creating` hook to provision a database when a tenant is created: |
| 244 | + |
| 245 | +```php |
| 246 | +protected static function booted(): void |
| 247 | +{ |
| 248 | + static::creating(fn (Tenant $tenant) => $tenant->createDatabase()); |
| 249 | +} |
| 250 | +``` |
| 251 | + |
| 252 | +## Migrations & Seeding |
| 253 | + |
| 254 | +**Landlord** migrations live in `database/migrations/landlord`. Run them once: |
| 255 | + |
| 256 | +```bash |
| 257 | +php artisan migrate --path=database/migrations/landlord --database=landlord |
| 258 | +``` |
| 259 | + |
| 260 | +**Tenant** migrations run for every tenant via `tenants:artisan`: |
| 261 | + |
| 262 | +```bash |
| 263 | +php artisan tenants:artisan "migrate --database=tenant" |
| 264 | +php artisan tenants:artisan "migrate --database=tenant --seed" --tenant=123 |
| 265 | +``` |
| 266 | + |
| 267 | +In seeders, branch on `Tenant::checkCurrent()`: |
| 268 | + |
| 269 | +```php |
| 270 | +public function run(): void |
| 271 | +{ |
| 272 | + Tenant::checkCurrent() |
| 273 | + ? $this->runTenantSpecificSeeders() |
| 274 | + : $this->runLandlordSpecificSeeders(); |
| 275 | +} |
| 276 | +``` |
| 277 | + |
| 278 | +Programmatic migrations use `MigrateTenantAction`: |
| 279 | + |
| 280 | +```php |
| 281 | +use Spatie\Multitenancy\Actions\MigrateTenantAction; |
| 282 | + |
| 283 | +app(MigrateTenantAction::class)->fresh()->seed()->execute($tenant); |
| 284 | +``` |
| 285 | + |
| 286 | +## Artisan Commands |
| 287 | + |
| 288 | +`tenants:artisan` loops over all tenants (or the specified ones) and runs a command for each: |
| 289 | + |
| 290 | +```bash |
| 291 | +php artisan tenants:artisan "migrate --database=tenant" |
| 292 | +php artisan tenants:artisan "cache:clear" --tenant=1 --tenant=2 |
| 293 | +``` |
| 294 | + |
| 295 | +To make your own commands tenant-aware, add the `TenantAware` concern and a `{--tenant=*}` option: |
| 296 | + |
| 297 | +```php |
| 298 | +use Illuminate\Console\Command; |
| 299 | +use Spatie\Multitenancy\Commands\Concerns\TenantAware; |
| 300 | + |
| 301 | +class SendReports extends Command |
| 302 | +{ |
| 303 | + use TenantAware; |
| 304 | + |
| 305 | + protected $signature = 'reports:send {--tenant=*}'; |
| 306 | + |
| 307 | + public function handle(): void |
| 308 | + { |
| 309 | + $this->line('Sending for tenant: ' . Tenant::current()->name); |
| 310 | + } |
| 311 | +} |
| 312 | +``` |
| 313 | + |
| 314 | +Omitting `--tenant` runs the command for every tenant. The command instance is reused across tenants — reset any state at the top of `handle()`. |
| 315 | + |
| 316 | +## Tenant-Aware Queues |
| 317 | + |
| 318 | +Enable globally in `config/multitenancy.php`: |
| 319 | + |
| 320 | +```php |
| 321 | +'queues_are_tenant_aware_by_default' => true, |
| 322 | +``` |
| 323 | + |
| 324 | +Or mark individual jobs with the `TenantAware` interface: |
| 325 | + |
| 326 | +```php |
| 327 | +use Illuminate\Contracts\Queue\ShouldQueue; |
| 328 | +use Spatie\Multitenancy\Jobs\TenantAware; |
| 329 | + |
| 330 | +class ProcessReport implements ShouldQueue, TenantAware |
| 331 | +{ |
| 332 | + public function handle(): void { /* ... */ } |
| 333 | +} |
| 334 | +``` |
| 335 | + |
| 336 | +Opt out per job with `NotTenantAware`: |
| 337 | + |
| 338 | +```php |
| 339 | +use Spatie\Multitenancy\Jobs\NotTenantAware; |
| 340 | + |
| 341 | +class SyncGlobalData implements ShouldQueue, NotTenantAware |
| 342 | +{ |
| 343 | + public function handle(): void { /* ... */ } |
| 344 | +} |
| 345 | +``` |
| 346 | + |
| 347 | +Or list classes in config: |
| 348 | + |
| 349 | +```php |
| 350 | +'tenant_aware_jobs' => [\App\Jobs\ProcessReport::class], |
| 351 | +'not_tenant_aware_jobs' => [\App\Jobs\SyncGlobalData::class], |
| 352 | +``` |
| 353 | + |
| 354 | +For closures dispatched to the queue, pass the tenant explicitly: |
| 355 | + |
| 356 | +```php |
| 357 | +$tenant = Tenant::current(); |
| 358 | + |
| 359 | +dispatch(function () use ($tenant) { |
| 360 | + $tenant->execute(function () { |
| 361 | + // tenant context is active here |
| 362 | + }); |
| 363 | +}); |
| 364 | +``` |
| 365 | + |
| 366 | +If a tenant-aware job fires but the tenant cannot be resolved, `CurrentTenantCouldNotBeDeterminedInTenantAwareJob` is thrown and the job is deleted from the queue. |
| 367 | + |
| 368 | +## Events |
| 369 | + |
| 370 | +All events live in the `Spatie\Multitenancy\Events` namespace and carry `public IsTenant $tenant` except where noted: |
| 371 | + |
| 372 | +| Event | When | |
| 373 | +|---|---| |
| 374 | +| `MakingTenantCurrentEvent` | Before switch tasks run | |
| 375 | +| `MadeTenantCurrentEvent` | After switch tasks + container binding | |
| 376 | +| `ForgettingCurrentTenantEvent` | Before forget tasks run | |
| 377 | +| `ForgotCurrentTenantEvent` | After forget tasks + container cleared | |
| 378 | +| `TenantNotFoundForRequestEvent` | When the finder returns `null` (carries `Request $request`) | |
| 379 | + |
| 380 | +## Performance |
| 381 | + |
| 382 | +- Switch tasks run synchronously on every `makeCurrent()` / `forgetCurrent()` call — keep them fast. |
| 383 | +- `shared_routes_cache` avoids generating one routes file per tenant when routes are identical across tenants. |
| 384 | +- Octane is supported out of the box: the service provider hooks into `RequestReceived` / `RequestTerminated` events automatically when `LARAVEL_OCTANE` is set. |
| 385 | +- The current tenant is stored in Laravel `Context` (`tenantId`), which queue workers read to restore tenant state before processing a job. |
0 commit comments