Most teams should start with single-database, tenant-scoped rows and only graduate to database-per-tenant when you have a clear reason (regulatory isolation, noisy-neighbor issues, or operational boundaries). But if your goal is database isolation—separate schemas or separate databases per tenant—you don’t need a multitenancy package to do it well in Laravel. You need three things you can own and reason about:
1) a reliable way to resolve the current tenant for every request/job, 2) a safe way to route queries to the tenant database, and 3) guardrails so you don’t accidentally query the landlord database with tenant credentials (or vice versa).
This article shows a production-ready pattern for database-per-tenant isolation using middleware, a tenant resolver, and a small amount of connection plumbing—no third-party multitenancy package. The bias here is intentional: packages are great until you hit an edge-case (queue workers, Octane, migrations, reporting queries, cross-tenant admin) and you realize you don’t understand the magic. Owning the primitives makes those cases boring.
Choose your isolation model (and don’t overbuild)
Before implementing anything, be honest about what you’re optimizing for.
Approach A: shared database + tenant_id scoping (default recommendation)
Pros: simplest ops, easiest reporting, one migration path, fewer connections.
Cons: weaker isolation; a missing where tenant_id = ? can leak data unless you enforce it hard (global scopes + policies + DB constraints).
Approach B: database-per-tenant (what we’re building)
Each tenant has its own database (or schema), and the app switches connections at runtime.
Pros: strong isolation, easier per-tenant backups/restore, per-tenant performance tuning, cleaner “delete tenant” story.
Cons: operational complexity (migrations across N DBs), connection management, cross-tenant reporting becomes a deliberate pipeline instead of a query.
Decision rule
If you’re early-stage or you need analytics-heavy cross-tenant queries, start with Approach A and enforce scoping. If you have clear isolation requirements or tenants big enough to justify their own DB lifecycle, Approach B is worth it.
The rest of this post assumes database-per-tenant.
The core architecture: landlord DB + tenant DB
You’ll typically have:
- A landlord (central) database containing tenants, domains, billing, feature flags, and audit metadata.
- A tenant database per tenant containing tenant-owned tables (users, projects, orders, etc.).
The application resolves the tenant from the request (domain/subdomain/header), loads tenant connection info from the landlord DB, then sets the current tenant connection for the duration of the request.
Laravel already gives you most of the tools:
-
Database connections via
config/database.php - Middleware for per-request initialization
-
Model connection selection via
$connectionorModel::on('connection') - Queue events and job middleware for worker-side initialization
The main thing you must get right: don’t let state leak between requests, especially under Laravel Octane.
Implement tenant resolution (domain-first, explicit, and testable)
Resolve tenants using a dedicated service. Keep it boring, deterministic, and easy to unit test.
Landlord models
In the landlord DB, store tenant connection details (or enough to derive them).
// app/Models/Landlord/Tenant.php
namespace App\Models\Landlord;
use Illuminate\Database\Eloquent\Model;
class Tenant extends Model
{
protected $connection = 'landlord';
protected $fillable = [
'name',
'slug',
'db_host',
'db_port',
'db_name',
'db_username',
'db_password',
'active',
];
protected $casts = [
'active' => 'bool',
];
}
If you don’t want to store raw passwords, use a secrets manager and store a reference. But don’t pretend you’re “more secure” by base64-encoding it in the DB.
Resolver service
Resolve by host (custom domains or subdomains). You can support multiple strategies, but pick one as primary.
// app/Tenancy/TenantResolver.php
namespace App\Tenancy;
use App\Models\Landlord\Tenant;
use Illuminate\Http\Request;
class TenantResolver
{
public function resolve(Request $request): ?Tenant
{
$host = $request->getHost();
// Example: tenant1.example.com -> tenant1
$baseDomain = config('tenancy.base_domain');
if ($baseDomain && str_ends_with($host, $baseDomain)) {
$subdomain = rtrim(str_replace('.'.$baseDomain, '', $host), '.');
if ($subdomain && $subdomain !== 'www') {
return Tenant::query()
->where('slug', $subdomain)
->where('active', true)
->first();
}
}
// Example: custom domain mapping
return Tenant::query()
->whereHas('domains', fn ($q) => $q->where('host', $host))
->where('active', true)
->first();
}
}
This implies a domains relation; if you don’t need it, drop it. The point is: resolution should be explicit and centralized.
Failure mode to design for
If tenant resolution fails, do not silently fall back to a default tenant DB. That’s how cross-tenant leaks happen.
Return a 404/410, or redirect to marketing, or show “Tenant not found”. But don’t proceed with tenant queries.
Switch the database connection safely (without global magic)
Laravel lets you define connections at runtime by mutating config and purging the connection so a new PDO is created.
The pattern that works in production:
- Keep a fixed
tenantconnection name. - On each request, overwrite
database.connections.tenantwith the resolved tenant credentials. - Call
DB::purge('tenant')thenDB::reconnect('tenant'). - Set a current tenant in a scoped container so your app can access it.
Tenancy manager
// app/Tenancy/TenancyManager.php
namespace App\Tenancy;
use App\Models\Landlord\Tenant;
use Illuminate\Support\Facades\DB;
class TenancyManager
{
public function initialize(Tenant $tenant): void
{
// Build a connection array compatible with config/database.php
$connection = [
'driver' => 'mysql',
'host' => $tenant->db_host,
'port' => $tenant->db_port ?? 3306,
'database' => $tenant->db_name,
'username' => $tenant->db_username,
'password' => $tenant->db_password,
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'prefix' => '',
'strict' => true,
'engine' => null,
// Consider setting options like SSL here if needed
];
config(['database.connections.tenant' => $connection]);
// Very important: drop any existing connection state
DB::purge('tenant');
DB::reconnect('tenant');
// Optional: set default connection for the request
DB::setDefaultConnection('tenant');
app(CurrentTenant::class)->set($tenant);
}
public function end(): void
{
// Reset to landlord to avoid accidental tenant queries later
DB::setDefaultConnection(config('database.default', 'landlord'));
// Purge tenant connection so Octane/long-running workers don’t reuse it
DB::purge('tenant');
app(CurrentTenant::class)->clear();
}
}
Current tenant holder
Don’t use a static global. Use the container.
// app/Tenancy/CurrentTenant.php
namespace App\Tenancy;
use App\Models\Landlord\Tenant;
class CurrentTenant
{
private ?Tenant $tenant = null;
public function set(Tenant $tenant): void
{
$this->tenant = $tenant;
}
public function get(): Tenant
{
if (!$this->tenant) {
throw new \RuntimeException('No tenant initialized for this context.');
}
return $this->tenant;
}
public function optional(): ?Tenant
{
return $this->tenant;
}
public function clear(): void
{
$this->tenant = null;
}
}
Register it as a singleton:
// app/Providers/AppServiceProvider.php
use App\Tenancy\CurrentTenant;
public function register(): void
{
$this->app->singleton(CurrentTenant::class, fn () => new CurrentTenant());
}
Middleware to wire it up
// app/Http/Middleware/InitializeTenancy.php
namespace App\Http\Middleware;
use App\Tenancy\TenantResolver;
use App\Tenancy\TenancyManager;
use Closure;
use Illuminate\Http\Request;
class InitializeTenancy
{
public function __construct(
private TenantResolver $resolver,
private TenancyManager $tenancy
) {}
public function handle(Request $request, Closure $next)
{
$tenant = $this->resolver->resolve($request);
if (!$tenant) {
abort(404);
}
$this->tenancy->initialize($tenant);
try {
return $next($request);
} finally {
$this->tenancy->end();
}
}
}
Apply it to tenant routes only (not your public marketing site, webhooks that aren’t tenant-specific, or landlord admin).
Octane note: the finally block is non-negotiable. Under long-running workers, forgetting to reset state is how tenant A’s connection bleeds into tenant B’s request.
Make models tenant-aware (and prevent accidental landlord access)
Once you set the default connection to tenant, most queries will go to the tenant DB. That’s convenient—and also dangerous if some code path should never touch tenant DB.
The clean approach is to be explicit:
- Landlord models always set
$connection = 'landlord'. - Tenant models always set
$connection = 'tenant'.
Tenant base model
// app/Models/Tenant/TenantModel.php
namespace App\Models\Tenant;
use Illuminate\Database\Eloquent\Model;
abstract class TenantModel extends Model
{
protected $connection = 'tenant';
}
Then:
// app/Models/Tenant/Project.php
namespace App\Models\Tenant;
class Project extends TenantModel
{
protected $fillable = ['name'];
}
This removes ambiguity: even if some code accidentally switches the default connection, your tenant models still target tenant.
Guardrail: block tenant queries when not initialized
If you want a hard fail instead of silent misrouting, add a connection “health check” at the model layer.
A pragmatic way is to ensure tenant middleware runs for routes that touch tenant models, and to throw if CurrentTenant is missing in sensitive service methods.
Example:
// app/Services/Projects/CreateProject.php
namespace App\Services\Projects;
use App\Models\Tenant\Project;
use App\Tenancy\CurrentTenant;
class CreateProject
{
public function __construct(private CurrentTenant $currentTenant) {}
public function handle(string $name): Project
{
// Hard requirement: no tenant, no write
$this->currentTenant->get();
return Project::create(['name' => $name]);
}
}
This is opinionated, but in real systems it prevents “it worked in dev” surprises.
Failure mode: cross-connection joins
Once you’re database-per-tenant, cross-database joins are not a feature, they’re a trap. If you need landlord + tenant data together, fetch them separately and combine in memory or build a reporting pipeline.
Example 1: Tenant-aware migrations without packages
Migrations are where database-per-tenant systems often collapse into ad-hoc scripts.
The pattern that scales: maintain two migration paths.
-
database/migrations/landlordfor central tables -
database/migrations/tenantfor tenant tables
Then run tenant migrations per tenant.
Configure migration paths
You can keep standard migrations for landlord and add a custom command for tenant.
// app/Console/Commands/TenantsMigrate.php
namespace App\Console\Commands;
use App\Models\Landlord\Tenant;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
class TenantsMigrate extends Command
{
protected $signature = 'tenants:migrate {--tenant_id=} {--fresh} {--seed}';
protected $description = 'Run tenant migrations for one or all tenants';
public function handle(): int
{
$query = Tenant::query()->where('active', true);
if ($id = $this->option('tenant_id')) {
$query->whereKey($id);
}
$tenants = $query->get();
foreach ($tenants as $tenant) {
$this->info("Migrating tenant {$tenant->id} ({$tenant->name})...");
config(['database.connections.tenant' => [
'driver' => 'mysql',
'host' => $tenant->db_host,
'port' => $tenant->db_port ?? 3306,
'database' => $tenant->db_name,
'username' => $tenant->db_username,
'password' => $tenant->db_password,
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'prefix' => '',
'strict' => true,
]]);
DB::purge('tenant');
DB::reconnect('tenant');
if ($this->option('fresh')) {
Artisan::call('migrate:fresh', [
'--database' => 'tenant',
'--path' => 'database/migrations/tenant',
'--force' => true,
]);
} else {
Artisan::call('migrate', [
'--database' => 'tenant',
'--path' => 'database/migrations/tenant',
'--force' => true,
]);
}
if ($this->option('seed')) {
Artisan::call('db:seed', [
'--database' => 'tenant',
'--force' => true,
]);
}
$this->output->write(Artisan::output());
}
return self::SUCCESS;
}
}
This is intentionally straightforward. You can optimize later (batching, parallelization, locking), but first make it correct.
Operational takeaway: if you have hundreds/thousands of tenants, tenant migrations become a deployment step that needs observability. Log per-tenant migration duration and failures, and never run them blindly during peak traffic.
Example 2: Queue jobs and scheduled tasks (where leaks actually happen)
Requests are easy. Workers and schedulers are where “no package” implementations usually fail.
The problem
A job runs later, on a different machine/process. If you don’t serialize tenant identity and re-initialize the connection, the job will run on whatever default connection the worker has.
Pattern: make jobs tenant-aware explicitly
Create a small trait that stores tenant_id and initializes tenancy in handle.
// app/Tenancy/Queue/TenantAware.php
namespace App\Tenancy\Queue;
use App\Models\Landlord\Tenant;
use App\Tenancy\TenancyManager;
trait TenantAware
{
public int $tenantId;
public function forTenant(int $tenantId): static
{
$this->tenantId = $tenantId;
return $this;
}
public function initializeTenancy(): void
{
$tenant = Tenant::query()
->whereKey($this->tenantId)
->where('active', true)
->firstOrFail();
app(TenancyManager::class)->initialize($tenant);
}
public function endTenancy(): void
{
app(TenancyManager::class)->end();
}
}
Use it in a job:
// app/Jobs/RecalculateUsage.php
namespace App\Jobs;
use App\Models\Tenant\Project;
use App\Tenancy\Queue\TenantAware;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class RecalculateUsage implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
use TenantAware;
public function __construct(int $tenantId)
{
$this->tenantId = $tenantId;
}
public function handle(): void
{
$this->initializeTenancy();
try {
// All tenant queries go to the tenant DB
Project::query()->chunkById(500, function ($projects) {
foreach ($projects as $project) {
// ...recalculate usage...
}
});
} finally {
$this->endTenancy();
}
}
}
Dispatching:
RecalculateUsage::dispatch($tenant->id);
Scheduler
For scheduled commands, do the same thing: iterate tenants and initialize tenancy per tenant, with a try/finally.
Practical takeaway: if you’re using Horizon, add tags like tenant:{id} for visibility. If you’re using Octane + queues, be even more strict about cleanup.
Hard edges: reporting, auth, and performance
Database-per-tenant isn’t hard because of the happy path. It’s hard because of the “one day you need…” path.
Cross-tenant reporting
Don’t fight your isolation model. If you need cross-tenant analytics:
- Emit events (orders created, invoice paid) into a central analytics store (landlord DB tables, ClickHouse, BigQuery, etc.).
- Or run ETL jobs that aggregate tenant data into landlord tables.
Trying to query N tenant databases on-demand inside a request is a self-inflicted outage.
Authentication and session storage
If your users live in tenant DBs, auth becomes tenant-contextual:
- Resolve tenant first.
- Then authenticate against tenant DB.
If you use Laravel’s session database driver, decide where sessions live. Most teams put sessions in a shared store (Redis) to avoid per-tenant session tables.
Official docs worth re-reading:
- Laravel database config: https://laravel.com/docs/database
- Laravel queues: https://laravel.com/docs/queues
- Laravel Octane (statefulness concerns): https://laravel.com/docs/octane
Connection churn and pooling
Switching tenant DB per request means more distinct connections. Mitigations:
- Use Redis/cache aggressively for landlord lookups (tenant by domain).
- Keep tenant credentials stable; avoid generating ephemeral DB users unless you have a strong reason.
- If you’re on MySQL/Postgres managed services, watch connection limits. Consider PgBouncer (Postgres) or a proxy/pooler.
Security posture
Database-per-tenant is not automatically secure if your app can still connect to all tenant DBs with the same credentials. The strongest model is:
- Each tenant has its own DB user limited to that tenant DB.
- The landlord DB holds encrypted credentials or references.
That’s extra ops, but it’s real isolation.
What I would ship first (and what I’d delay)
Here’s the opinionated path that keeps you out of trouble:
1) Ship TenantResolver + TenancyManager + middleware with strict failure behavior (no tenant, no access).
2) Make landlord vs tenant models explicit with $connection properties.
3) Add tenant-aware jobs early, even if you think you “don’t use queues much”. You will.
4) Add tenant migration command and run it in CI/staging with multiple tenants.
Delay until you actually need them:
- Fancy cross-tenant query abstractions
- Per-tenant read replicas
- Automatic tenant provisioning with zero-touch credentials rotation
Rule of thumb to remember
If there is any chance the code runs outside an HTTP request (queues, scheduler, Octane worker), assume tenant context is missing and make initialization explicit. In multitenancy, “implicit defaults” are just bugs waiting for a customer to find them.
Read the full post on QCode: https://qcode.in/laravel-multitenancy-database-isolation-without-packages/
Top comments (0)