Running a multi-tenant software-as-a-service application means isolating customer data completely. Single-database setups relying on global tenant ID columns always break down eventually due to a missed scope or an eager loading leak. Moving to a dedicated database per tenant solves those data safety issues at the storage layer. Laravel makes runtime connection switching straightforward, but you still have to build custom safety checks to keep your queries pointed at the correct schema.
You need a central database for global records like tenant lookups, billing states, and user domain mappings. Each tenant gets their own separate database instance or schema. When a request hits your application, middleware identifies the tenant from the hostname or path and configures a dynamic connection on the fly. You never want to rely on global state blindly. Instead, bind the resolved tenant database configuration directly into the application container so repositories and jobs pull the right context.
Let's talk about what happens when you skip connection purging. It's a disaster. If you don't clear the pool, queries bleed into neighboring accounts.
Switching Database Connections at Runtime
Laravel reads database configurations from the config/database.php file. You can modify these runtime arrays using the Config facade before executing any Eloquent queries. If you don't purge the old connection from the database manager instance, Laravel will keep using the cached PDO connection from the previous request cycle. That leakage causes catastrophic data exposure. Don't let it happen to your users.
Here is a middleware class that resolves the tenant, dynamically writes a temporary database connection config, and purges the stale manager state.
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\DB;
use App\Models\Tenant;
use Symfony\Component\HttpFoundation\Response;
class IdentifyTenant
{
public function handle(Request $request, Closure $next): Response
{
$subdomain = $request->route('subdomain');
$tenant = Tenant::where('subdomain', $subdomain)->firstOrFail();
Config::set('database.connections.tenant.database', $tenant->database_name);
Config::set('database.connections.tenant.username', $tenant->database_user);
Config::set('database.connections.tenant.password', $tenant->database_password);
DB::purge('tenant');
DB::reconnect('tenant');
DB::setDefaultConnection('tenant');
app()->instance(Tenant::class, $tenant);
return $next($request);
}
}
After this middleware executes, any Eloquent model that uses the default connection targets the tenant database automatically. If you need to read from the central database inside a tenant request, you must specify the connection explicitly using on('mysql') or the connection('mysql') method. It's an extra step, but it keeps things explicit.
Background jobs require extra care. They don't inherit the HTTP request lifecycle, meaning your queue workers won't magically know which tenant owns the job payload. You'll need to implement custom job middleware or serialize the tenant ID directly into the job constructor so it can re-establish the correct database connection before handling the payload.
Running Tenant Migrations Safely
Managing migrations across dozens or hundreds of tenant databases requires an automated artisan command. You cannot run standard migrations because they target the default connection configuration. You need to iterate over every registered tenant, clone or update the connection configuration, and execute the migration path pointing specifically at that database.
You should use this approach when building deployment pipelines or provisioning new accounts. For a deeper look into architecture strategies before choosing this route, read Laravel Multi Tenancy: Single vs Multi Database to confirm your trade-offs. It'll save you from architecting yourself into a corner.
Here is an artisan command that loops through active tenants and runs migration files against their respective databases.
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Artisan;
use App\Models\Tenant;
class MigrateTenants extends Command
{
protected $signature = 'tenants:migrate';
protected $description = 'Run migrations for all tenant databases';
public function handle(): void
{
Tenant::each(function (Tenant $tenant) {
$this->info("Migrating tenant: {$tenant->id}");
Config::set('database.connections.tenant.database', $tenant->database_name);
Config::set('database.connections.tenant.username', $tenant->database_user);
Config::set('database.connections.tenant.password', $tenant->database_password);
DB::purge('tenant');
DB::reconnect('tenant');
Artisan::call('migrate', [
'--database' => 'tenant',
'--path' => 'database/migrations/tenant',
'--force' => true,
]);
});
}
}
Running database updates sequentially like this works fine for small tenant counts. As you scale past a few hundred databases, executing synchronous artisan calls inside a single command thread will block deployments for hours. You'll want to dispatch individual migration jobs onto an isolated queue queue instead. That way, deployments finish quickly and failures are contained to specific tenants.
Cross-Database Query Safety Checks
The scariest bug in a multi-database architecture is a stray query that reads or writes central data inside a tenant request loop, or worse, queries another tenant's database. Developers often write joins or subqueries that accidentally reference central tables without qualifying the connection properly. You can catch these mistakes by listening to database query events during local development and testing.
You should register a query listener in your AppServiceProvider that inspects incoming SQL strings or table names. If a tenant request attempts to query a table restricted to the central database, throw an exception immediately. This prevents accidental data leaks during code reviews and automated test suites.
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\App;
use RuntimeException;
class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
if (App::environment('production')) {
return;
}
DB::listen(function ($query) {
if (app()->bound(\App\Models\Tenant::class)) {
$sql = strtolower($query->sql);
if (str_contains($sql, 'from central_users') || str_contains($sql, 'from global_settings')) {
throw new RuntimeException("Forbidden cross-database query detected in tenant context: {$query->sql}");
}
}
});
}
}
Always verify your queue workers as well. When a job runs in the background, it loses the HTTP request context. You must serialize the tenant identifier onto the job payload and restore the database connection inside the job's constructor or middleware stack before executing queries.
Production safety requires continuous monitoring and strict database user permissions. Grant your tenant database users access only to their specific schema or database name, and deny them any privileges on the central tables. It's an extra layer of defense that stops accidental query leakage from turning into an actual data breach.











