Multi-tenancy in web applications refers to the architecture where a single instance of the application serves multiple customers, or 'tenants.' Each tenant's data and, sometimes, specific configurations are kept isolated from others. This setup is essential in SaaS (Software as a Service) platforms where multiple businesses or organizations might use the same application.
In this guide, we’ll walk through setting up a database-per-tenant approach in Laravel, complete with code examples, detailed explanations, and the necessary console commands for creating the database itself with no need or usage of any external package.
Step 1: Setting Up Database Connections
In the config/database.php file, we define two connections:
1. ** Owner Connection**: This connection manages the information about all tenants, such as their domain and database names.
2. **Tenant Connection**: This connection dynamically switches based on the tenant currently being accessed.
Here’s how to set up these connections:
return [ 'default' => env('DB_CONNECTION', 'tenant'), 'connections' => [ 'tenant' => [ 'driver' => 'mysql', 'host' => env('DB_HOST', '127.0.0.1'), 'port' => env('DB_PORT', '3306'), 'database' => null, // Database will be set dynamically 'username' => env('DB_USERNAME', 'root'), 'password' => env('DB_PASSWORD', ''), 'charset' => 'utf8mb4', 'collation' => 'utf8mb4_unicode_ci', 'prefix' => '', 'strict' => true, ], ' owner' => [ 'driver' => 'mysql', 'host' => env(‘OWNER_DB_HOST', '127.0.0.1'), 'port' => env(,OWNER _DB_PORT', '3306'), 'database' => env(' OWNER _DB_DATABASE', 'landlord'), 'username' => env(' OWNER _DB_USERNAME', 'root'), 'password' => env(' OWNER _DB_PASSWORD', ''), 'charset' => 'utf8mb4', 'collation' => 'utf8mb4_unicode_ci', 'prefix' => '', 'strict' => true, ], ], ];
Step 2: Creating the Tenant Model and Migrations
We need to create a Tenant model that uses the landlord connection. This model will manage tenant-related data such as the domain and database name.
Tenant Migration:
In the migration for the tenants table, we'll include columns for the tenant’s name, domain, and the specific database used:
Schema::create('tenants', function (Blueprint $table) { $table->id(); $table->string('name'); $table->string('domain')->unique(); $table->string('database'); $table->timestamps(); });
Tenant Model:
The Tenant model should be linked to the landlord connection and include methods for configuring and using the tenant:
class Tenant extends Model { use HasFactory; protected $fillable = [ 'name', 'domain', 'database', ]; protected $connection = 'owner'; // Default connection for the owner database /** * Configure the tenant's database connection dynamically. */ public function configure(): self { // Update the configuration for the tenant connection dynamically config([ 'database.connections.tenant.database' => $this->database, ]); // Purge the 'tenant' connection to refresh its settings DB::purge('tenant'); // Clear tenant-specific cache if cache table exists if (Schema::hasTable('cache')) { $this->clearTenantCache(); } return $this; } /** * Activate the tenant context across the application. */ public function use(): self { // Set the default database connection to 'tenant' DB::setDefaultConnection('tenant'); return $this; } /** * Clear cache specific to the tenant. */ public function clearTenantCache(): void { // Get tenant-specific cache keys $cacheKeys = Cache::get('tenant_'.$this->id.'_keys', []); // Forget each cache key foreach ($cacheKeys as $key) { Cache::forget($key); } // Optionally remove the keys tracking itself Cache::forget('tenant_'.$this->id.'_keys'); } /** * Add a tenant-specific cache key. */ public function addCacheKey(string $key): void { // Get the current list of cache keys for the tenant $cacheKeys = Cache::get('tenant_'.$this->id.'_keys', []); // Add the new cache key $cacheKeys[] = $key; // Update the list in the cache Cache::put('tenant_'.$this->id.'_keys', $cacheKeys); } }
Step 3: Custom Console Command for Tenant initialization
We will create console command to initialize the owner table where all tenant database info will be stored
class TenantInit extends Command { protected $signature = 'tenants:init'; protected $description = 'Create owner table where all domains for tenant app live'; public function handle(): int { DB::setDefaultConnection('owner'); $path = database_path('migrations/owner'); // Custom migration path for the owner DB $this->info('Running migrations from: ' . $path); try { $this->call('migrate', ['--path' => $path, '--force' => true]); $this->info('Migrations have been executed successfully.'); } catch (\Exception $e) { $this->error('An error occurred: ' . $e->getMessage()); return 1; // Return non-zero for failure } return 0; // Return zero for success } }
Step 4: Custom Console Command for Tenant Migrations
Since each tenant has its own database, we need a way to migrate the schema for each one. Laravel’s built-in migrate command only runs migrations on the default connection, so we’ll create a custom Artisan command to handle tenant migrations.
TenantsMigrateCommand:
This command will loop through all tenants, configure the tenant’s database connection, and run migrations on each database:
class TenantsMigrateCommand extends Command
{ /** * The name and signature of the console command. * * @var string */ protected $signature = 'tenants:migrate {tenant?} {--fresh} {--seed}'; /** * The console command description. * * @var string */ protected $description = 'Create clean migration and seed for one tenant or for all tenants'; /** * Execute the console command. */ public function handle(): void { if ($tenantId = $this->argument('tenant')) { $tenant = Tenant::find($tenantId); if ($tenant) { $this->migrate($tenant); } else { $this->error("Tenant with ID {$tenantId} not found."); } } else { Tenant::all()->each( fn ($tenant) => $this->migrate($tenant) ); } } /** * Migrate the given tenant. */ public function migrate(Tenant $tenant): void { $tenant->configure()->use(); $this->line(''); $this->line('-----------------------------------------'); $this->info("Migrating Tenant #{$tenant->id} ({$tenant->name})"); $this->line('-----------------------------------------'); $options = ['--force' => true]; if ($this->option('seed')) { $options['--seed'] = true; } $this->call( $this->option('fresh') ? 'migrate:fresh' : 'migrate', $options ); } }
• {tenant?}: The tenant argument is now optional. If provided, it will run migrations for the specified tenant only.
• {--fresh}: Option to run migrate:fresh, which drops all tables and re-runs all migrations.
• {--seed}: Option to seed the database after running migrations.
Step 5: Middleware for Tenant Resolution
To ensure that the correct tenant is used for each request, we’ll create middleware that identifies the tenant based on the domain and configures the application accordingly.
TenantSessionMiddleware:
class TenantSessionMiddleware { public function handle(Request $request, Closure $next): Response { if (! $request->session()->has('tenant_id')) { $request->session()->put('tenant_id', app('tenant')->id); return $next($request); } if ($request->session()->get('tenant_id') != app('tenant')->id) { abort(401); } return $next($request); }}
Step 6: Testing Multi-Tenant Applications
Testing in a multi-tenant environment requires that both the landlord and tenant databases are properly set up before each test. Here’s how you can configure your tests:
public function setUp(): void { parent::setUp(); // Migrate the owner's database and seed data $this->artisan('migrate', ['--database' => 'owner']); $this->seed(OwnerSeeder::class); // Loop through tenants and migrate their databases Tenant::all()->each(function (Tenant $tenant) { $tenant->configure(); $this->artisan('migrate', ['--database' => 'tenant']); }); }
Step 7: Setting Up a Service Provider for Tenant Resolution
In a multi-tenant application, it’s crucial to ensure that the correct tenant context is established for each incoming request. This is where a service provider comes into play. The service provider will configure the tenant based on the request's domain or subdomain, ensuring that the correct tenant's database and other settings are used throughout the application lifecycle.
Creating the TenancyServiceProvider
<?php namespace App\Providers; use App\Models\Tenant; use Illuminate\Queue\Events\JobProcessing; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Queue; use Illuminate\Support\ServiceProvider; class TenancyServiceProvider extends ServiceProvider { /** * Register services. */ public function register(): void { // } /** * Bootstrap services. */ public function boot(): void { $this->configureTenant(); $this->configureQueue(); } /** * Configure tenant based on the domain. */ protected function configureTenant(): void { if ($this->app->runningInConsole()) { return; } $host = request()->getHost(); $tenant = Tenant::whereDomain($host)->firstOrFail(); $tenant->configure()->use(); // Set up and use tenant configuration } /** * Configure the queue system to be aware of tenants. */ protected function configureQueue(): void { // Add tenant_id to the job payload Queue::createPayloadUsing(function () { if (app()->bound('tenant')) { $tenant = app()->make('tenant'); return ['tenant_id' => $tenant->id]; } return []; }); // Restore tenant context when job is processing Event::listen(JobProcessing::class, function (JobProcessing $event) { $tenantId = $event->job->payload()['tenant_id'] ?? null; if ($tenantId) { $tenant = Tenant::find($tenantId); $tenant?->configure()->use(); } }); } }
Expanded Explanation of Service Provider Methods
The methods you've shared are essential for managing tenant context both during normal HTTP requests and within Laravel's queue system. These methods ensure that the correct tenant is always in scope, whether you're handling web requests or processing background jobs.
Method 1: configureTenant
protected function configureTenant(): void { if ($this->app->runningInConsole()) { return; } $host = request()->getHost(); $tenant = Tenant::whereDomain($host)->firstOrFail(); $tenant->configure()->use(); // Set up and use tenant configuration }
Explanation:
Purpose: This method sets up the tenant configuration based on the incoming request's domain and ensures that the correct tenant is used throughout the request lifecycle.
if ($this->app->runningInConsole()) { return; }:
This check ensures that the tenant configuration logic only runs during HTTP requests. If the application is running in the console (e.g., during migrations or other Artisan commands), the method will exit early. This is because console commands may not be associated with any particular tenant, and running tenant-specific logic could cause unintended behavior.
$host = request()->getHost();:
This retrieves the domain name from the incoming HTTP request. The domain is typically used to determine which tenant the request is for.
$tenant = Tenant::whereDomain($host)->firstOrFail();:
This line queries the tenants table to find the tenant that matches the domain name. The firstOrFail() method will throw a ModelNotFoundException if no tenant is found, which can result in a 404 error being returned to the user.
$tenant->configure()->use();:
Once the tenant is found, the configure() method sets up the database connection and other tenant-specific configurations.
The use() method then registers this tenant in the service container, making it accessible throughout the application for the duration of the request.
Method 2: configureQueue
/** * Configure the queue system to be aware of tenants. */ protected function configureQueue(): void { // Add tenant_id to the job payload Queue::createPayloadUsing(function () { if (app()->bound('tenant')) { $tenant = app()->make('tenant'); return ['tenant_id' => $tenant->id]; } return []; }); // Restore tenant context when job is processing Event::listen(JobProcessing::class, function (JobProcessing $event) { $tenantId = $event->job->payload()['tenant_id'] ?? null; if ($tenantId) { $tenant = Tenant::find($tenantId); $tenant?->configure()->use(); } }); }
Explanation:
Purpose: This method ensures that Laravel's queue system is tenant-aware. By attaching the tenant_id to the job payload, and reconfiguring the tenant context during job processing, we ensure that the job runs in the correct tenant's context. This is particularly crucial for distributed queue systems like Redis, where workers could be shared across tenants.
Queue::createPayloadUsing():
This method hooks into the queue system to modify the job payload. The payload is essentially the data that is passed along with a job when it is queued.
if ($this->app->bound('tenant')) {:
This checks if the application has a tenant bound to the service container. If a tenant is bound, it means the current request is associated with a specific tenant.
$tenant = $this->app->make('tenant');:
This retrieves the currently bound tenant from the service container.
return ['tenant_id' => $tenant->id];:
This adds the tenant_id to the job payload, ensuring that the tenant context is passed along with the job when it is dispatched.
return [];:
If no tenant is bound, the payload remains unchanged.
Event::listen(JobProcessing::class, ...):
This listens for the JobProcessing event, which is fired just before a queued job starts processing.
$tenantId = $event->job->payload()['tenant_id'] ?? null;:
This retrieves the tenant_id from the job's payload. If no tenant_id is found, it returns null.
$tenant = Tenant::find($tenantId);:
This finds the tenant by the tenant_id stored in the payload.
$tenant?->configure()->use();:
If a tenant is found, the configure() method sets up the tenant's database connection, and use() registers this tenant as the current tenant for the job's processing context.
You can create a service provider that handles the tenant resolution and configuration. This service provider will hook into Laravel’s bootstrapping process and set up the tenant context at the beginning of each request.
Conclusion
Implementing multi-tenancy in Laravel using a database-per-tenant approach offers strong data isolation and scalability. The steps outlined in this guide provide a complete framework for setting up multi-tenancy, handling migrations, and ensuring that your application uses the correct database for each tenant.
Pros:
• Data Isolation: Each tenant’s data is completely separated, ensuring strong data isolation.
• Scalability: This approach allows easy scaling by adding new databases for new tenants.
• Security: By isolating each tenant's data in separate databases, the risk of data leakage between tenants is minimized.
• Cache Efficiency: The use of tenant-specific cache keys ensures that only tenant-relevant cached data is cleared, improving performance.
• Queue Robustness: Tenant context is preserved in queues, even in distributed environments, preventing cross-tenant data leakage in background tasks.
Cons:
• Complexity: Setting up and managing multiple databases adds complexity to the application architecture.
• Resource Overhead: Maintaining separate databases for each tenant requires more infrastructure resources.
• Backup Complexity: Managing backups across multiple databases can be challenging, especially when scaling to hundreds or thousands of tenants.
• Migration Overhead: Running migrations for each tenant adds additional overhead compared to a single-database architecture.
• By following this approach, you can build robust, scalable multi-tenant applications in Laravel.
the code example can be locale at :https://github.com/KalimeroMK/multitenant