Single database vs multiple databases vs schema-per-tenant — the three architectures explained with real trade-offs, Spatie’s multi-tenancy package from scratch, tenant-aware queues, storage isolation, and the migrations strategy that doesn’t bring your app down.
Multi-tenancy is the architecture that makes SaaS possible. One application, one codebase, one deployment — but hundreds or thousands of customers, each seeing only their own data, each feeling like the application was built just for them.
It’s also the architecture that causes the most catastrophic data leaks when implemented incorrectly. The wrong query returns data for the wrong tenant. An eager-loaded relationship crosses a tenant boundary. A background job processes the wrong customer’s data.
This post covers the complete picture: the three architectural strategies and their real trade-offs, a full implementation of spatie/laravel-multitenancy v4 from scratch, tenant-aware queues, storage isolation, and the migration strategy that runs across all tenants without bringing your application down.
The Three Multi-Tenancy Architectures
Before writing a line of code, the most important decision: which database architecture fits your application. This decision is very hard to reverse once you have customers.
Architecture 1: Single Database, Shared Tables
All tenants share the same database tables. Every tenant-owned table has a tenant_id column. Queries are scoped by this column to prevent cross-tenant data access.
┌─────────────────────────────────────┐
│ Single Database │
│ │
│ users (tenant_id, name, email) │
│ orders (tenant_id, total, ...) │
│ products (tenant_id, name, ...) │
└─────────────────────────────────────┘
Tenant A, Tenant B, Tenant C
all in the same tables
Pros:
- Simplest to set up and operate
- Easiest to query across tenants (for aggregate analytics)
- One set of migrations applies to all tenants instantly
- Cheapest infrastructure — one database server for all customers
Cons:
- A missing
tenant_idWHERE clause exposes all tenants’ data — catastrophic bug potential - One noisy tenant can degrade performance for all others
- Compliance challenges — GDPR/HIPAA “delete all data” requires finding every row for that tenant across all tables
- Table sizes grow very large with many tenants
Best for: Applications with many small tenants, low compliance requirements, tight infrastructure budgets, early-stage SaaS.
Architecture 2: Multiple Databases (One Per Tenant)
Each tenant has their own dedicated database. The application switches the active database connection when a tenant is identified.
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Tenant A DB │ │ Tenant B DB │ │ Tenant C DB │
│ │ │ │ │ │
│ users │ │ users │ │ users │
│ orders │ │ orders │ │ orders │
│ products │ │ products │ │ products │
└──────────────┘ └──────────────┘ └──────────────┘
Pros:
- Complete data isolation — a bug cannot cross tenant boundaries
- Easy compliance — delete a tenant = drop a database
- Performance isolation — one tenant’s heavy queries don’t affect others
- Per-tenant backup, restore, and point-in-time recovery
Cons:
- More expensive — each database requires its own resources
- Migrations must run against every tenant database (slower deployments)
- Cross-tenant queries (global analytics) require federated queries or a data warehouse
- Database connection pooling becomes complex at scale (100+ tenants = 100+ connections)
Best for: Enterprise SaaS with compliance requirements, large tenants with heavy data volumes, applications where data isolation is a selling point.
Architecture 3: Schema-Per-Tenant (PostgreSQL)
PostgreSQL supports multiple schemas within one database. Each tenant gets their own schema — complete table isolation, but within a single database server.
┌─────────────────────────────────────────────────┐
│ Single PostgreSQL Database │
│ │
│ schema: tenant_a │ schema: tenant_b │
│ ───────────────── │ ───────────────── │
│ users │ users │
│ orders │ orders │
│ products │ products │
└─────────────────────────────────────────────────┘
Pros:
- Better isolation than shared tables without the full cost of separate databases
- Easy tenant deletion (drop schema)
- Single database server to manage
Cons:
- PostgreSQL only — not available in MySQL/MariaDB
- Schema switching adds query overhead
- Migration complexity similar to multiple databases
- Less isolation than separate databases (still shares server resources)
Best for: PostgreSQL-native applications, mid-tier isolation requirements, teams already experienced with PostgreSQL schemas.
The Decision Framework
Do you need strict compliance (GDPR, HIPAA, SOC2)?
├── Yes → Multiple databases
└── No
│
Are tenants enterprise-sized with heavy data volumes?
├── Yes → Multiple databases
└── No
│
Are you running PostgreSQL and need moderate isolation?
├── Yes → Schema-per-tenant
└── No
└── Single database with tenant_id scoping
(most SaaS startups start here)
The Landlord and Tenant Concept
spatie/laravel-multitenancy introduces a key mental model: landlord and tenant.
- Landlord: The central application that owns tenant records, handles billing, and manages cross-tenant concerns. Stored in the “main” database.
- Tenants: Individual customers. Each has their own data context (either a separate database, a tenant_id scope, or a schema).
Landlord Database (your main app DB)
├── tenants table (id, name, domain, database, ...)
├── plans table
└── invoices table
Tenant Databases (one per tenant, for multi-DB mode)
├── users
├── orders
└── products
Installation
composer require spatie/laravel-multitenancy
php artisan vendor:publish --provider="Spatie\Multitenancy\MultitenancyServiceProvider" --tag="multitenancy-migrations"
php artisan vendor:publish --provider="Spatie\Multitenancy\MultitenancyServiceProvider" --tag="multitenancy-config"
php artisan migrate
This creates the tenants table in your landlord database. Each row represents one customer.
The Config File
// config/multitenancy.php (published — key settings)
return [
// The class responsible for finding the current tenant
// DomainTenantFinder looks up tenant by request domain
'tenant_finder' => Spatie\Multitenancy\TenantFinder\DomainTenantFinder::class,
// Your custom Tenant model (must extend Spatie's Tenant)
'tenant_model' => App\Models\Tenant::class,
// Tasks that run when a tenant becomes "current"
// These switch the environment for the incoming request
'switch_tenant_tasks' => [
// Uncomment the ones you need:
// Spatie\Multitenancy\Tasks\SwitchTenantDatabaseTask::class,
// Spatie\Multitenancy\Tasks\PrefixCacheTask::class,
// Spatie\Multitenancy\Tasks\SwitchRouteCacheTask::class,
App\Multitenancy\Tasks\SwitchTenantStorageTask::class, // custom task
],
// Queue jobs carry the current tenant's ID automatically
'queues_are_tenant_aware_by_default' => true,
// DB connection name for the landlord (main) database
'landlord_database_connection_name' => env('DB_CONNECTION', 'mysql'),
// DB connection name for tenant databases
'tenant_database_connection_name' => 'tenant',
];
The Tenant Model
Extend Spatie’s Tenant model to add your application-specific fields:
// app/Models/Tenant.php
<?php
namespace App\Models;
use Spatie\Multitenancy\Models\Tenant as SpatieTenant;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Tenant extends SpatieTenant
{
protected $fillable = [
'name',
'domain',
'database', // for multi-DB architecture
'plan',
'storage_path', // for isolated file storage
'settings',
];
protected $casts = [
'settings' => 'array',
];
// Creating a tenant programmatically (e.g., during onboarding)
public static function provision(string $name, string $domain): self
{
$tenant = static::create([
'name' => $name,
'domain' => $domain,
'database' => 'tenant_' . str($name)->slug('_'),
'storage_path' => 'tenants/' . str($name)->slug('-'),
'plan' => 'trial',
]);
// Create the tenant's database (multi-DB architecture)
$tenant->createDatabase();
// Run the tenant migrations
$tenant->runMigrations();
return $tenant;
}
public function createDatabase(): void
{
// Create the physical database
\DB::statement("CREATE DATABASE IF NOT EXISTS `{$this->database}`");
}
public function runMigrations(): void
{
// Switch to this tenant and run migrations
$this->makeCurrent();
\Artisan::call('migrate', [
'--database' => 'tenant',
'--path' => 'database/migrations/tenant',
'--force' => true,
]);
static::forgetCurrent();
}
}
Setting Up Single Database Architecture
For single-database multi-tenancy with tenant_id scoping:
// config/multitenancy.php — no database switching task
'switch_tenant_tasks' => [
Spatie\Multitenancy\Tasks\PrefixCacheTask::class,
// No SwitchTenantDatabaseTask — we stay on one database
],
Global Scopes on Every Tenant Model
// app/Traits/BelongsToTenant.php
<?php
namespace App\Traits;
use Spatie\Multitenancy\Models\Tenant;
trait BelongsToTenant
{
public static function bootBelongsToTenant(): void
{
// Automatically scope every query to the current tenant
static::addGlobalScope('tenant', function ($query) {
if ($currentTenant = Tenant::current()) {
$query->where('tenant_id', $currentTenant->id);
}
});
// Automatically assign tenant_id on create
static::creating(function ($model) {
if (!$model->tenant_id && $currentTenant = Tenant::current()) {
$model->tenant_id = $currentTenant->id;
}
});
}
}
// app/Models/Order.php
class Order extends Model
{
use BelongsToTenant;
// Every query is automatically scoped to the current tenant
// SELECT * FROM orders WHERE tenant_id = ? AND ...
}
Security note: The global scope approach is convenient but has a critical failure mode: if
Tenant::current()returns null (no tenant identified), the scope is skipped and all tenants’ data is exposed. Always use theNeedsTenantmiddleware on tenant routes to ensure a tenant is always current.
Setting Up Multiple Database Architecture
For database-per-tenant, enable the SwitchTenantDatabaseTask:
// config/multitenancy.php
'switch_tenant_tasks' => [
Spatie\Multitenancy\Tasks\SwitchTenantDatabaseTask::class,
Spatie\Multitenancy\Tasks\PrefixCacheTask::class,
],
'tenant_database_connection_name' => 'tenant',
// config/database.php — add the tenant connection
'connections' => [
'mysql' => [ /* your landlord (main) connection */ ],
'tenant' => [
'driver' => 'mysql',
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => '', // empty — filled dynamically by SwitchTenantDatabaseTask
'username' => env('DB_USERNAME'),
'password' => env('DB_PASSWORD'),
'charset' => 'utf8mb4',
'collation'=> 'utf8mb4_unicode_ci',
],
],
When SwitchTenantDatabaseTask runs, it reads the database attribute from the current Tenant model and updates the tenant connection to point to that database. Every subsequent query on models using the tenant connection hits the correct database.
Model Connection Traits
// Models that live in the LANDLORD database (tenants, plans, billing)
class Plan extends Model
{
use Spatie\Multitenancy\Models\Concerns\UsesLandlordConnection;
}
// Models that live in the TENANT database (users, orders, products)
class Order extends Model
{
use Spatie\Multitenancy\Models\Concerns\UsesTenantConnection;
}
Determining the Current Tenant
The DomainTenantFinder looks up the tenant by matching the request’s domain against the domain column in the tenants table.
// This is the built-in DomainTenantFinder — works automatically
// tenant1.yourapp.com → looks up Tenant where domain = 'tenant1.yourapp.com'
// tenant2.yourapp.com → looks up Tenant where domain = 'tenant2.yourapp.com'
Custom Tenant Finder (Subdomain + Custom Domain)
Many SaaS applications support both customer.yourapp.com AND custom domains like app.theircustomdomain.com. Build a custom finder:
// app/Multitenancy/TenantFinder/DomainOrSubdomainTenantFinder.php
<?php
namespace App\Multitenancy\TenantFinder;
use App\Models\Tenant;
use Illuminate\Http\Request;
use Spatie\Multitenancy\Contracts\IsTenant;
use Spatie\Multitenancy\TenantFinder\TenantFinder;
class DomainOrSubdomainTenantFinder extends TenantFinder
{
public function findForRequest(Request $request): ?IsTenant
{
$host = $request->getHost();
// First: check for exact domain match (custom domains)
$tenant = Tenant::where('domain', $host)->first();
if ($tenant) return $tenant;
// Second: check for subdomain match
$appDomain = config('app.domain', 'yourapp.com');
if (str_ends_with($host, '.' . $appDomain)) {
$subdomain = str_replace('.' . $appDomain, '', $host);
return Tenant::where('subdomain', $subdomain)->first();
}
return null;
}
}
// config/multitenancy.php
'tenant_finder' => App\Multitenancy\TenantFinder\DomainOrSubdomainTenantFinder::class,
Routes and Middleware
// routes/web.php
// Landlord routes — no tenant required (marketing pages, login, signup)
Route::middleware('web')->group(function () {
Route::get('/', [MarketingController::class, 'home']);
Route::get('/pricing', [MarketingController::class, 'pricing']);
Route::post('/register', [RegistrationController::class, 'store']);
});
// Tenant routes — NeedsTenant ensures a tenant is always identified
Route::middleware(['web', 'needsTenant'])->group(function () {
Route::get('/dashboard', [DashboardController::class, 'index']);
Route::apiResource('orders', OrderController::class);
Route::apiResource('products', ProductController::class);
});
The NeedsTenant middleware (provided by the package) aborts with a 404 if no tenant can be identified from the request. This prevents any tenant routes from executing without a current tenant.
Tenant-Aware Queues
Queue jobs dispatched while a tenant is current automatically carry the tenant’s ID and re-establish the tenant context when the job executes:
// config/multitenancy.php
'queues_are_tenant_aware_by_default' => true,
With this enabled, when you dispatch any job inside a tenant request:
// In a controller with Tenant A as current:
ProcessInvoice::dispatch($invoice)
// The job is stored with tenant_id = Tenant A's ID
// When the queue worker picks it up, it restores Tenant A as current
// The job runs in Tenant A's database context automatically
Opting Out of Tenant Awareness
For jobs that should run without a tenant context (global operations, billing jobs):
// A job that should NOT be tenant-aware
class GenerateGlobalReport implements ShouldQueue
{
use NotTenantAware; // opts out of automatic tenant binding
public function handle(): void
{
// No tenant is current — can query across all tenants
Tenant::all()->eachCurrent(function (Tenant $tenant) {
// Process each tenant explicitly
});
}
}
Processing All Tenants in a Job
// Run a task for every tenant sequentially
class SendMonthlyDigest implements ShouldQueue
{
public function handle(): void
{
Tenant::all()->eachCurrent(function (Tenant $tenant) {
// Each iteration switches to this tenant's context
$users = User::wherePreference('monthly_digest', true)->get();
$users->each(fn($user) => Mail::to($user)->send(new MonthlyDigestMail($user)));
});
}
}
Storage Isolation
File uploads must be isolated per tenant. A user from Tenant A must never be able to access Tenant B’s files.
Custom Task: Switch Storage Path
// app/Multitenancy/Tasks/SwitchTenantStorageTask.php
<?php
namespace App\Multitenancy\Tasks;
use Spatie\Multitenancy\Contracts\IsTenant;
use Spatie\Multitenancy\Tasks\SwitchTenantTask;
class SwitchTenantStorageTask implements SwitchTenantTask
{
public function makeCurrent(IsTenant $tenant): void
{
$this->setStoragePath($tenant->storage_path);
}
public function forgetCurrent(): void
{
$this->setStoragePath('app'); // reset to default
}
private function setStoragePath(string $path): void
{
// Override the default disk's root path for this tenant
config(['filesystems.disks.local.root' => storage_path($path)]);
config(['filesystems.disks.public.root' => public_path('storage/' . $path)]);
// Clear the filesystem manager's resolved instances so it picks up the new config
app('filesystem')->forgetDisk('local');
app('filesystem')->forgetDisk('public');
}
}
// Register in config/multitenancy.php
'switch_tenant_tasks' => [
Spatie\Multitenancy\Tasks\SwitchTenantDatabaseTask::class,
Spatie\Multitenancy\Tasks\PrefixCacheTask::class,
App\Multitenancy\Tasks\SwitchTenantStorageTask::class,
],
Now every Storage::put() and Storage::get() call automatically uses the current tenant’s isolated directory. No code changes needed in controllers or services.
Cache Isolation
The built-in PrefixCacheTask prefixes every cache key with the tenant’s ID:
// With PrefixCacheTask enabled:
Cache::put('products', $products)
// Actually stored as: "tenant_42:products"
// Another tenant's Cache::put('products', ...) stores:
// "tenant_99:products"
// No collision, no cross-tenant cache reads
// config/multitenancy.php
'switch_tenant_tasks' => [
Spatie\Multitenancy\Tasks\PrefixCacheTask::class,
// ...
],
The Migration Strategy
Running migrations across hundreds of tenant databases is the biggest operational challenge of multi-tenancy. The wrong strategy can take your application down for hours.
Separate Migration Paths
Keep landlord and tenant migrations in separate directories:
database/migrations/
├── landlord/ ← migrations for the main/landlord database
│ ├── 2024_01_01_create_tenants_table.php
│ ├── 2024_01_02_create_plans_table.php
│ └── 2024_01_03_create_billing_table.php
└── tenant/ ← migrations for each tenant database
├── 2024_01_01_create_users_table.php
├── 2024_01_02_create_orders_table.php
└── 2024_01_03_create_products_table.php
Running Landlord Migrations
# Run only landlord migrations
php artisan migrate --path=database/migrations/landlord
Running Tenant Migrations
# Run migrations for ALL tenants (built into the package)
php artisan tenants:artisan "migrate --path=database/migrations/tenant --force"
This command iterates over every tenant, makes each one current, and runs the artisan command in that tenant’s context. For the multi-database architecture, this means each tenant’s database is migrated independently.
The Safe Deployment Migration Strategy
Running tenant migrations synchronously during deployment blocks the deploy process and can timeout for large numbers of tenants. The production-safe approach:
Step 1: Add a column that’s nullable or has a default
Never add a NOT NULL column without a default in a migration that runs while the application is live. The application will fail to insert rows between the migration running and the deploy completing.
// ✗ Dangerous in live deployment
$table->string('timezone')->notNull(); // existing rows have no value!
// ✓ Safe — nullable with a sensible default
$table->string('timezone')->nullable()->default('UTC');
Step 2: Queue tenant migrations as background jobs
// app/Console/Commands/MigrateTenantsInBackground.php
class MigrateTenantsInBackground extends Command
{
protected $signature = 'tenants:migrate-background {--path=database/migrations/tenant}';
public function handle(): void
{
$path = $this->option('path');
$count = 0;
Tenant::all()->each(function (Tenant $tenant) use ($path, &$count) {
MigrateTenantJob::dispatch($tenant, $path)
->delay(now()->addSeconds($count * 2)); // stagger to avoid DB overload
$count++;
});
$this->info("Queued migration jobs for {$count} tenants.");
}
}
// app/Jobs/MigrateTenantJob.php
class MigrateTenantJob implements ShouldQueue
{
use NotTenantAware; // this job IS tenant-aware internally but sets its own context
public function __construct(
private Tenant $tenant,
private string $migrationPath,
) {}
public function handle(): void
{
$this->tenant->makeCurrent();
try {
Artisan::call('migrate', [
'--database' => 'tenant',
'--path' => $this->migrationPath,
'--force' => true,
]);
} finally {
Tenant::forgetCurrent();
}
}
}
Deploy script:
#!/bin/bash
# 1. Deploy new code
# 2. Run landlord migrations immediately
php artisan migrate --path=database/migrations/landlord --force
# 3. Queue tenant migrations in background (non-blocking)
php artisan tenants:migrate-background
# 4. App is live — tenant migrations run in background via queue workers
# 5. Monitor queue for failures
Working With the Current Tenant
// Get the current tenant anywhere
$tenant = Tenant::current();
// Check if a tenant is currently active
Tenant::checkCurrent(); // → bool
// Execute code for a specific tenant
$specificTenant = Tenant::find(42);
$specificTenant->execute(function () {
// This runs in Tenant 42's context
$orders = Order::count();
Log::info("Tenant 42 has {$orders} orders");
});
// Iterate every tenant
Tenant::all()->eachCurrent(function (Tenant $tenant) {
// Switch to each tenant's context in sequence
$this->generateReport($tenant);
});
// Run tenant-scoped Artisan commands
$tenant->artisan('cache:clear');
Onboarding: Creating a New Tenant
The complete provisioning flow for a new customer signup:
// app/Actions/ProvisionNewTenant.php
class ProvisionNewTenant
{
public function execute(
string $companyName,
string $subdomain,
string $planId,
): Tenant {
return DB::transaction(function () use ($companyName, $subdomain, $planId) {
// 1. Create tenant record in landlord database
$tenant = Tenant::create([
'name' => $companyName,
'subdomain' => $subdomain,
'domain' => "{$subdomain}.yourapp.com",
'database' => "tenant_{$subdomain}",
'storage_path' => "tenants/{$subdomain}",
'plan_id' => $planId,
]);
// 2. Create the physical database (multi-DB mode)
\DB::statement("CREATE DATABASE `{$tenant->database}`");
// 3. Switch to tenant context and run migrations
$tenant->makeCurrent();
Artisan::call('migrate', [
'--database' => 'tenant',
'--path' => 'database/migrations/tenant',
'--force' => true,
]);
// 4. Seed default data for the tenant
Artisan::call('db:seed', [
'--class' => TenantDefaultDataSeeder::class,
'--force' => true,
]);
// 5. Create storage directories
Storage::makeDirectory(''); // creates the tenant's root directory
Storage::makeDirectory('avatars');
Storage::makeDirectory('documents');
Tenant::forgetCurrent();
// 6. Send welcome email (landlord context — uses tenant info)
// Mail::to($adminEmail)->send(new WelcomeMail($tenant));
return $tenant;
});
}
}
Testing Multi-Tenant Applications
// tests/TestCase.php — base test case for tenant tests
abstract class TenantTestCase extends TestCase
{
use RefreshDatabase;
protected Tenant $tenant;
protected function setUp(): void
{
parent::setUp();
$this->tenant = Tenant::factory()->create([
'domain' => 'test.localhost',
'database' => 'tenant_test',
]);
// Make this tenant current for all tests in this class
$this->tenant->makeCurrent();
}
protected function tearDown(): void
{
Tenant::forgetCurrent();
parent::tearDown();
}
}
// tests/Feature/OrderTest.php
class OrderTest extends TenantTestCase
{
public function test_orders_are_scoped_to_current_tenant(): void
{
// Create orders for the current tenant
Order::factory()->count(5)->create();
// Create a second tenant with its own orders
$otherTenant = Tenant::factory()->create();
$otherTenant->execute(function () {
Order::factory()->count(3)->create();
});
// The current tenant should only see their 5 orders
$this->assertCount(5, Order::all());
}
}
The Production Checklist
Architecture:
✓ Architecture chosen before first customer (single DB, multi DB, or schema)
✓ Tenant identification strategy defined (domain, subdomain, custom domain, header)
✓ NeedsTenant middleware on all tenant routes
Data isolation:
✓ Every tenant model uses BelongsToTenant trait (single DB) or UsesTenantConnection (multi DB)
✓ Global scope verified — returns empty collection when no tenant is current, not all rows
✓ SwitchTenantStorageTask isolates file uploads per tenant
✓ PrefixCacheTask isolates cache per tenant
Queues:
✓ queues_are_tenant_aware_by_default = true
✓ Global jobs (billing, reporting) use NotTenantAware trait
✓ Queue worker restarts after deployment (tenant context is set at job pickup time)
Migrations:
✓ Landlord and tenant migrations in separate directories
✓ All tenant migrations add nullable columns or have defaults
✓ Tenant migrations run as background jobs in production
✓ Migration failures monitored via queue failure logging
Onboarding:
✓ New tenant provisioning creates database, runs migrations, and seeds defaults atomically
✓ Tenant deletion cleans up: database, storage directory, cache keys
Testing:
✓ TenantTestCase base class makes a tenant current for every test
✓ Cross-tenant data leakage tested explicitly
✓ Global (landlord) tests run without any tenant current
Final Thoughts
Multi-tenancy is one of the most consequential architectural decisions in a SaaS application. Getting it wrong means data leaks between customers — a failure that can destroy a company. Getting it right means a single codebase serving hundreds of customers reliably, with each feeling like the application was built just for them.
spatie/laravel-multitenancy makes the right choice possible without building the scaffolding from scratch. The package is deliberately minimal — it provides tenant finding, tenant switching, tenant-aware queues, and the task system for customising what happens when a tenant becomes current. Everything else — your models, your business logic, your onboarding flow — stays in your application code.
The single most important thing in this guide is not a line of code. It’s the decision you make before writing any code: which architecture fits your compliance requirements, your tenant size distribution, and your budget. Make that decision deliberately. The rest is implementation.
