How to setup multi-tenant Database connection in Laravel
When developing multi-tenant applications, there are two approaches in Laravel to managing the database. The first approach is single-database multi-tenancy. This approach requires that you add a tenant_id column to all your tables and manage scopes with global scopes. The second approach is a database per tenant. Using this approach, each customer has their own database, isolated from others.
Isolated databases help to ensure data separation, simplify backups and restores at a database and client level, and avoid issues such as accidentally omitting a WHERE tenant_id = ? clause in queries. On the other hand, the second approach requires solving the issue of how to switch connections dynamically and at the moment of query execution within a single request or job.
In this article, we will discuss how to implement the necessary tools natively in Laravel without using a heavy third-party package. We will look at how to configure the database, identify the tenant in the middleware, and implement dynamic binding of connections in the database and caching, as well as the specifics of queue jobs and queue workers.
How Dynamic Database Switching Works in Laravel
Laravel's database manager reads its configuration from the config/database.php file. Typically, developers define their connections there, such as mysql or pgsql. However, Laravel allows you to change it at runtime using the Config facade and purge or reconnect the connections using the DB facade.
The process is divided into four steps, which are as follows:
- An HTTP request comes to the server (e.g., the request URL has a subdomain client1.yourapp.com or some custom header with a tenant identifier);
- A middleware reads the tenant identifier from the database;
- The application sets the connection credentials for the tenant's database in the configuration;
- Laravel purges the connection with the same name and reconnects using the new credentials. All Eloquent queries will use the tenant's database.
Step 1: Set Up Database Configurations
Open config/database.php. You need two connection definitions: a landlord (Central / the Super Admin) connection and a tenant connection template.
'default' => env('DB_CONNECTION', 'landlord'),
'connections' => [
// Central landlord database where tenant records are stored
'landlord' => [
'driver' => 'mysql',
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_LANDLORD_DATABASE', 'saas_landlord'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'prefix' => '',
'strict' => true,
],
// Template connection modified at runtime for each tenant
'tenant' => [
'driver' => 'mysql',
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => null, // Set dynamically
'username' => null, // Set dynamically
'password' => null, // Set dynamically
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'prefix' => '',
'strict' => true,
],
],
Step 2: The Tenant Model on the Landlord Connection
The Tenant model should be stored in your central landlord database. It contains the subdomain (or domain) and the connection information for the tenant's database.
Because the default connection may change, it is necessary to explicitly set the $connection property for landlord:
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Tenant extends Model
{
// Always query this model from the central landlord database
protected $connection = 'landlord';
protected $fillable = [
'name',
'subdomain',
'database_name',
'database_user',
'database_password',
];
}
Step 3: Building the TenantManager Service
Create a dedicated service class to handle configuring, switching, and purging the tenant connection. This centralizes connection logic so both HTTP requests and queue workers can use the same code.
Create app/Services/TenantManager.php:
namespace App\Services;
use App\Models\Tenant;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\DB;
class TenantManager
{
protected ?Tenant $currentTenant = null;
// Switch the database connection to the given tenant.
public function switchToTenant(Tenant $tenant): void
{
// Disconnect and purge any previous tenant connection from memory
DB::purge('tenant');
// Override the tenant connection configuration at runtime
Config::set('database.connections.tenant.database', $tenant->database_name);
Config::set('database.connections.tenant.username', $tenant->database_user ?? config('database.connections.landlord.username'));
Config::set('database.connections.tenant.password', $tenant->database_password ?? config('database.connections.landlord.password'));
// Reconnect to instantiate the connection with new credentials
DB::reconnect('tenant');
// Set the default connection to 'tenant' so standard queries use it
DB::setDefaultConnection('tenant');
$this->currentTenant = $tenant;
}
/**
* Switch back to the central landlord database.
*/
public function switchToLandlord(): void
{
DB::purge('tenant');
DB::setDefaultConnection('landlord');
$this->currentTenant = null;
}
/**
* Get the active tenant instance.
*/
public function getTenant(): ?Tenant
{
return $this->currentTenant;
}
}
Register TenantManager as a singleton in app/Providers/AppServiceProvider.php:
namespace App\Providers;
use App\Services\TenantManager;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(TenantManager::class, function () {
return new TenantManager();
});
}
}
Step 4: Tenant Resolution Middleware
Next, write a middleware that intercepts incoming HTTP requests, detects the tenant, and triggers the switch. In this example, we identify the tenant by looking at the subdomain (such as acme.app.test):
namespace App\Http\Middleware;
use App\Models\Tenant;
use App\Services\TenantManager;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class IdentifyTenant
{
public function __construct(
protected TenantManager $tenantManager
) {}
public function handle(Request $request, Closure $next): Response
{
$host = $request->getHost();
$parts = explode('.', $host);
// Assume the first segment is the tenant subdomain (e.g., 'acme' from 'acme.app.test')
$subdomain = $parts[0] ?? null;
$tenant = Tenant::where('subdomain', $subdomain)->first();
if (! $tenant) {
abort(404, 'Tenant not found.');
}
// Switch the database connection dynamically
$this->tenantManager->switchToTenant($tenant);
return $next($request);
}
}
Assign this middleware to your tenant-specific routes in bootstrap/app.php or within your route files:
use App\Http\Middleware\IdentifyTenant;
use Illuminate\Support\Facades\Route;
Route::middleware([IdentifyTenant::class])->group(function () {
Route::get('/dashboard', [DashboardController::class, 'index']);
Route::get('/orders', [OrderController::class, 'index']);
});
Step 5: Writing Tenant Models and Controllers
Because the TenantManager sets DB::setDefaultConnection('tenant'), your tenant models do not need any special traits, scopes, or connection overrides. They are standard, clean Eloquent models:
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Order extends Model
{
// Uses the current default connection ('tenant') automatically
protected $fillable = [
'customer_name',
'total_amount',
'status',
];
}
Your controllers run standard Eloquent code, completely oblivious to which database they are querying:
namespace App\Http\Controllers;
use App\Models\Order;
use Illuminate\Http\JsonResponse;
class OrderController extends Controller
{
public function index(): JsonResponse
{
// Runs "SELECT * FROM orders" on the active tenant's isolated database
$orders = Order::latest()->paginate(20);
return response()->json($orders);
}
}
Handling Queue Workers in Multi-Tenant Environments
One of the most frequent issues when it comes to switching databases dynamically is queued background jobs. The worker process constantly listens in the background. So if you have Job A for Tenant 1 and then right after it is processed, Job B is for Tenant 2, the worker process has to know what connection to use for each job.
To solve that, just store the tenant_id on the job class and switch connections inside the job's handle() or use a job middleware:
namespace App\Jobs;
use App\Models\Tenant;
use App\Services\TenantManager;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class ProcessTenantMonthlyReport implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
public int $tenantId
) {}
public function handle(TenantManager $tenantManager): void
{
// Load the tenant from the central landlord database
$tenant = Tenant::findOrFail($this->tenantId);
// Switch to this tenant's isolated database
$tenantManager->switchToTenant($tenant);
// Process records from the tenant database
// Clean up connection before returning to worker pool
$tenantManager->switchToLandlord();
}
}
Running Tenant-Specific Migrations
With separate databases, running php artisan migrate only migrates your central landlord database. To run migrations across tenant databases, create an Artisan command that iterates through all tenants:
namespace App\Console\Commands;
use App\Models\Tenant;
use App\Services\TenantManager;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Artisan;
class MigrateTenantsCommand extends Command
{
protected $signature = 'tenants:migrate';
protected $description = 'Run migrations across all tenant databases';
public function handle(TenantManager $tenantManager): int
{
$tenants = Tenant::all();
foreach ($tenants as $tenant) {
$this->info("Migrating tenant: {$tenant->name} ({$tenant->database_name})");
$tenantManager->switchToTenant($tenant);
Artisan::call('migrate', [
'--database' => 'tenant',
'--path' => 'database/migrations/tenant',
'--force' => true,
]);
$this->line(Artisan::output());
}
$tenantManager->switchToLandlord();
$this->info('All tenant databases migrated successfully.');
return Command::SUCCESS;
}
}
Keep your migrations clean by placing central migrations in database/migrations and tenant migrations in a dedicated subfolder like database/migrations/tenant.
Important Things to Remember
- Always Purge Connections Before Reconnecting: Simply calling Config::set() won't update an active PDO connection that's already been resolved by DB::reconnect(). You need to call DB::purge('tenant') before DB::reconnect('tenant') if you need to drop the old connection handle.
- Isolate the Cache Driver: If you're using Redis or Memcached, you'll need to prefix your cache keys with the tenant ID, or configure the cache store to use a dynamic key prefix. For example, Config::set('cache.prefix', "tenant_{$tenant->id}").
- Isolate the File Storage: If your tenants are uploading files somewhere, you'll need to make sure they're either going to different storage buckets or their paths are prefixed in some way. You might want to configure your storage disks' root paths dynamically inside your tenant manager.
- MySQL has connection Limits: MySQL has a limit on the number of open connections per process, so making a connection for each of your 10,000 tenants is going to be a problem if they all want to be connected at once. You might want to look into connection poolers like ProxySQL or PgBouncer if you have a large number of active tenants.
Conclusion
Database-per-tenant multi-tenancy gives us the peace of mind of knowing data is safely isolated, and allows clear operational boundaries of each tenant. By having central landlord database, runtime configuration overrides, and a dedicated service for managing it (like a TenantManager), we can route database connections at run time cleanly without over-complicating our Eloquent models with repetitive scope logic.
Thank you for reading this article 😊
For any query, do not hesitate to comment 💬
-compressed.jpg)