Introduction
Multi-tenancy is the backbone of modern SaaS applications. Whether you're building a project management tool, CRM, or e-commerce platform, the ability to serve multiple customers (tenants) from a single application instance is crucial for scalability and cost efficiency.
Laravel 12 brings powerful features that make building multi-tenant applications more intuitive than ever. With enhanced database capabilities, improved queue handling, and better performance optimization, Laravel 12 is perfectly positioned for enterprise-grade SaaS development.
In this comprehensive guide, we'll explore how to architect, build, and deploy production-ready multi-tenant SaaS applications using Laravel 12. We'll cover everything from tenant isolation strategies to security considerations, backed by real code examples and best practices.
Understanding Multi-Tenancy Architecture
Before diving into implementation, let's understand what multi-tenancy means and why it matters for your SaaS business.
What is Multi-Tenancy?
Multi-tenancy is an architecture where a single instance of your application serves multiple customers (tenants). Each tenant's data remains isolated and invisible to other tenants, while sharing the same application codebase and infrastructure.
Benefits of Multi-Tenancy:
- Cost Efficiency — Shared infrastructure reduces hosting and maintenance costs
- Easier Updates — Deploy once, all tenants get the update simultaneously
- Scalability — Add new tenants without deploying new instances
- Simplified Maintenance — Single codebase to monitor and maintain
- Resource Optimization — Better utilization of server resources
Common Use Cases:
- Project management platforms (Asana, Monday.com)
- Customer relationship management systems
- E-commerce platforms with multiple stores
- Learning management systems
- Help desk and ticketing systems
Multi-Tenancy Strategies in Laravel 12
Laravel 12 supports three primary approaches to multi-tenancy. Let's explore each strategy with its pros, cons, and ideal use cases.
1. Single Database with Tenant Column
This is the simplest approach where all tenants share the same database, and a tenant_id column differentiates the data.
Pros:
- Simplest to implement
- Cost-effective for small to medium applications
- Easy to manage and backup
- Efficient resource utilization
Cons:
- Risk of data leakage if not properly implemented
- Performance can degrade with massive data growth
- Less flexibility for tenant-specific customizations
- Backup/restore affects all tenants
Best For: Applications with similar tenant requirements, predictable data growth, and budget constraints.
Implementation Example:
// Migration
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('projects', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->onDelete('cascade');
$table->string('name');
$table->text('description');
$table->timestamps();
$table->index(['tenant_id', 'created_at']);
});
}
};
// Model with Global Scope
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Project extends Model
{
protected $fillable = ['name', 'description', 'tenant_id'];
protected static function booted(): void
{
static::addGlobalScope('tenant', function (Builder $builder) {
if (auth()->check()) {
$builder->where('tenant_id', auth()->user()->tenant_id);
}
});
static::creating(function (Model $model) {
if (auth()->check() && !$model->tenant_id) {
$model->tenant_id = auth()->user()->tenant_id;
}
});
}
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
}
2. Database Per Tenant
Each tenant gets their own dedicated database. This provides maximum isolation and flexibility.
Pros:
- Complete data isolation
- Tenant-specific performance optimization
- Easy to backup/restore individual tenants
- Simpler to customize per tenant
- Better compliance with data residency requirements
Cons:
- Higher infrastructure costs
- More complex database management
- Challenging to perform cross-tenant analytics
- Migration complexity across multiple databases
Best For: Enterprise SaaS applications, regulated industries, applications with varying tenant sizes.
Implementation Example:
// config/database.php
'connections' => [
'tenant' => [
'driver' => 'mysql',
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => null, // Set dynamically
'username' => env('DB_USERNAME', 'forge'),
'password' => env('DB_PASSWORD', ''),
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'prefix' => '',
'strict' => true,
'engine' => null,
],
],
// Tenant Model
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Tenant extends Model
{
protected $fillable = ['name', 'domain', 'database_name', 'is_active'];
protected $casts = [
'is_active' => 'boolean',
];
public function configure(): void
{
config([
'database.connections.tenant.database' => $this->database_name,
]);
DB::purge('tenant');
DB::reconnect('tenant');
}
public function users(): HasMany
{
return $this->hasMany(User::class);
}
}
// Middleware to switch database
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use App\Models\Tenant;
use Symfony\Component\HttpFoundation\Response;
class TenantDatabase
{
public function handle(Request $request, Closure $next): Response
{
$domain = $request->getHost();
$tenant = Tenant::where('domain', $domain)
->where('is_active', true)
->firstOrFail();
$tenant->configure();
app()->instance('tenant', $tenant);
return $next($request);
}
}
3. Schema Per Tenant (PostgreSQL)
Multiple schemas within a single database, each representing a tenant. This is a PostgreSQL-specific approach.
Pros:
- Balance between isolation and management
- Easier than managing multiple databases
- Good performance characteristics
- Shared connection pool
Cons:
- PostgreSQL only
- More complex than single database
- Schema-level permissions required
- Limited adoption in Laravel community
Best For: PostgreSQL users wanting isolation without database proliferation.
Implementation Example:
// Tenant Model with Schema Support
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;
class Tenant extends Model
{
protected $fillable = ['name', 'domain', 'schema_name'];
public function configure(): void
{
$schemaName = $this->schema_name;
DB::statement("SET search_path TO {$schemaName}");
config([
'database.connections.pgsql.search_path' => $schemaName,
]);
}
public function createSchema(): void
{
DB::statement("CREATE SCHEMA IF NOT EXISTS {$this->schema_name}");
// Run migrations for this schema
$this->configure();
Artisan::call('migrate', ['--force' => true]);
}
}
Implementing Multi-Tenancy with Spatie Laravel-Multitenancy
The Spatie Laravel-Multitenancy package is the most popular and well-maintained solution for Laravel. It supports multiple tenancy strategies and integrates seamlessly with Laravel 12.
Installation
composer require spatie/laravel-multitenancy
php artisan vendor:publish --provider="Spatie\Multitenancy\MultitenancyServiceProvider"
php artisan migrate
Configuration
// config/multitenancy.php
return [
'tenant_model' => App\Models\Tenant::class,
'tenant_finder' => Spatie\Multitenancy\TenantFinder\DomainTenantFinder::class,
'switch_tenant_tasks' => [
Spatie\Multitenancy\Tasks\SwitchTenantDatabase::class,
App\Tasks\SwitchTenantCache::class,
App\Tasks\SwitchTenantQueue::class,
],
'queues_are_tenant_aware_by_default' => true,
'tenant_database_connection_name' => 'tenant',
];
Creating the Tenant Model
namespace App\Models;
use Spatie\Multitenancy\Models\Tenant as BaseTenant;
class Tenant extends BaseTenant
{
protected $fillable = [
'name',
'domain',
'database',
'plan',
'trial_ends_at',
];
protected $casts = [
'trial_ends_at' => 'datetime',
];
public function makeCurrent(): self
{
if ($this->isCurrent()) {
return $this;
}
static::forgetCurrent();
$this->getKey()
? app()->instance('currentTenant', $this)
: app()->forgetInstance('currentTenant');
foreach (config('multitenancy.switch_tenant_tasks') as $taskClass) {
(new $taskClass())->makeCurrent($this);
}
event(new TenantMadeCurrent($this));
return $this;
}
public function isOnTrial(): bool
{
return $this->trial_ends_at && $this->trial_ends_at->isFuture();
}
}
Domain-Based Tenant Identification
// routes/web.php
use Illuminate\Support\Facades\Route;
use Spatie\Multitenancy\Http\Middleware\NeedsTenant;
Route::middleware([NeedsTenant::class])->group(function () {
Route::get('/dashboard', function () {
$tenant = tenant();
return view('dashboard', compact('tenant'));
})->name('dashboard');
Route::resource('projects', ProjectController::class);
Route::resource('users', UserController::class);
});
// Landlord routes (no tenant required)
Route::get('/', function () {
return view('welcome');
});
Route::get('/pricing', function () {
return view('pricing');
});
Database Design Best Practices
Proper database design is critical for multi-tenant applications. Here are key considerations:
Tenant Isolation Strategy
// Central database migration
Schema::create('tenants', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('domain')->unique();
$table->string('database')->unique();
$table->string('plan')->default('free');
$table->timestamp('trial_ends_at')->nullable();
$table->boolean('is_active')->default(true);
$table->json('settings')->nullable();
$table->timestamps();
$table->softDeletes();
$table->index(['domain', 'is_active']);
});
// Tenant database migration
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->string('role')->default('member');
$table->rememberToken();
$table->timestamps();
$table->index('email');
});
Indexing Strategy
For single database approach, always index the tenant_id column:
$table->index(['tenant_id', 'created_at']);
$table->index(['tenant_id', 'status']);
$table->index(['tenant_id', 'user_id']);
Soft Deletes for Compliance
use Illuminate\Database\Eloquent\SoftDeletes;
class Invoice extends Model
{
use SoftDeletes;
protected $fillable = ['tenant_id', 'amount', 'due_date', 'status'];
// Keep deleted records for audit trails
public function scopeIncludingTrashed($query)
{
return $query->withTrashed();
}
}
Security Considerations
Security is paramount in multi-tenant applications. A single vulnerability can expose data across all tenants.
1. Prevent Data Leakage
// Always use global scopes
namespace App\Models\Concerns;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
trait BelongsToTenant
{
protected static function bootBelongsToTenant(): void
{
static::addGlobalScope('tenant', function (Builder $builder) {
if ($tenantId = app('currentTenant')?->id) {
$builder->where('tenant_id', $tenantId);
}
});
static::creating(function (Model $model) {
if (!$model->tenant_id && $tenantId = app('currentTenant')?->id) {
$model->tenant_id = $tenantId;
}
});
}
public function tenant()
{
return $this->belongsTo(Tenant::class);
}
}
2. Request Validation
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreProjectRequest extends FormRequest
{
public function authorize(): bool
{
return auth()->check();
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'client_id' => [
'required',
Rule::exists('clients', 'id')
->where('tenant_id', tenant()->id)
],
];
}
}
3. Policy-Based Authorization
namespace App\Policies;
use App\Models\User;
use App\Models\Project;
class ProjectPolicy
{
public function view(User $user, Project $project): bool
{
return $user->tenant_id === $project->tenant_id;
}
public function update(User $user, Project $project): bool
{
return $user->tenant_id === $project->tenant_id
&& ($user->isAdmin() || $project->user_id === $user->id);
}
public function delete(User $user, Project $project): bool
{
return $user->tenant_id === $project->tenant_id
&& $user->isAdmin();
}
}
4. Queue Job Tenant Awareness
namespace App\Jobs;
use App\Models\Tenant;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Spatie\Multitenancy\Jobs\TenantAware;
class ProcessInvoice implements ShouldQueue, TenantAware
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
public int $invoiceId,
public ?Tenant $tenant = null
) {
$this->tenant = tenant();
}
public function handle(): void
{
$this->tenant->makeCurrent();
$invoice = Invoice::findOrFail($this->invoiceId);
// Process invoice logic
}
}
Performance Optimization
Multi-tenant applications require careful performance tuning to ensure all tenants receive consistent service.
1. Connection Pooling
// config/database.php
'tenant' => [
'driver' => 'mysql',
'pool' => [
'min_connections' => 2,
'max_connections' => 10,
],
'options' => [
PDO::ATTR_PERSISTENT => true,
],
],
2. Tenant-Specific Caching
namespace App\Tasks;
use Spatie\Multitenancy\Tasks\SwitchTenantTask;
use Illuminate\Support\Facades\Cache;
class SwitchTenantCache implements SwitchTenantTask
{
public function makeCurrent(Tenant $tenant): void
{
Cache::setPrefix("tenant_{$tenant->id}_");
}
public function forgetCurrent(): void
{
Cache::setPrefix('');
}
}
// Usage
Cache::remember('projects', 3600, function () {
return Project::with('users')->get();
});
3. Query Optimization
// Eager loading to prevent N+1
$projects = Project::with(['client', 'tasks.assignee'])
->where('status', 'active')
->latest()
->paginate(20);
// Use select to limit columns
$users = User::select(['id', 'name', 'email', 'role'])
->where('is_active', true)
->get();
// Database-level calculations
$stats = Project::query()
->selectRaw('COUNT(*) as total')
->selectRaw('SUM(budget) as total_budget')
->selectRaw('AVG(budget) as avg_budget')
->where('tenant_id', tenant()->id)
->first();
4. Horizon for Queue Management
composer require laravel/horizon
php artisan horizon:install
// config/horizon.php
'environments' => [
'production' => [
'supervisor-1' => [
'connection' => 'redis',
'queue' => ['default', 'tenant'],
'balance' => 'auto',
'processes' => 10,
'tries' => 3,
'timeout' => 300,
],
],
],
Tenant Onboarding Flow
A smooth onboarding experience is crucial for SaaS success. Here's a complete tenant registration implementation:
namespace App\Services;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
class TenantOnboardingService
{
public function createTenant(array $data): Tenant
{
return DB::transaction(function () use ($data) {
// Create tenant
$tenant = Tenant::create([
'name' => $data['company_name'],
'domain' => Str::slug($data['company_name']) . '.yourapp.com',
'database' => 'tenant_' . Str::uuid(),
'plan' => 'trial',
'trial_ends_at' => now()->addDays(14),
]);
// Create tenant database
$this->createTenantDatabase($tenant);
// Switch to tenant context
$tenant->makeCurrent();
// Create admin user
$user = User::create([
'name' => $data['name'],
'email' => $data['email'],
'password' => Hash::make($data['password']),
'role' => 'admin',
]);
// Seed initial data
$this->seedTenantData($tenant, $user);
// Send welcome email
$user->sendWelcomeNotification();
return $tenant;
});
}
protected function createTenantDatabase(Tenant $tenant): void
{
$database = $tenant->database;
DB::statement("CREATE DATABASE `{$database}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci");
$tenant->configure();
Artisan::call('migrate', [
'--database' => 'tenant',
'--force' => true,
]);
}
protected function seedTenantData(Tenant $tenant, User $user): void
{
// Create default project statuses
$statuses = ['todo', 'in_progress', 'review', 'done'];
foreach ($statuses as $status) {
ProjectStatus::create(['name' => $status]);
}
// Create sample project
Project::create([
'name' => 'Welcome Project',
'description' => 'Get started with your first project',
'user_id' => $user->id,
]);
}
}
// Controller usage
public function store(RegisterTenantRequest $request)
{
$tenant = app(TenantOnboardingService::class)
->createTenant($request->validated());
Auth::login($tenant->users()->first());
return redirect()
->route('dashboard')
->with('success', 'Welcome to your new workspace!');
}
Testing Multi-Tenant Applications
Testing requires special considerations to ensure tenant isolation works correctly.
namespace Tests\Feature;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Project;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class TenantIsolationTest extends TestCase
{
use RefreshDatabase;
public function test_users_can_only_see_their_tenant_projects(): void
{
$tenant1 = Tenant::factory()->create();
$tenant2 = Tenant::factory()->create();
$tenant1->makeCurrent();
$user1 = User::factory()->create();
$project1 = Project::factory()->create();
$tenant2->makeCurrent();
$user2 = User::factory()->create();
$project2 = Project::factory()->create();
// Test tenant 1
$tenant1->makeCurrent();
$this->actingAs($user1);
$response = $this->get('/api/projects');
$response->assertOk();
$response->assertJsonCount(1, 'data');
$response->assertJsonFragment(['id' => $project1->id]);
$response->assertJsonMissing(['id' => $project2->id]);
// Test tenant 2
$tenant2->makeCurrent();
$this->actingAs($user2);
$response = $this->get('/api/projects');
$response->assertOk();
$response->assertJsonCount(1, 'data');
$response->assertJsonFragment(['id' => $project2->id]);
$response->assertJsonMissing(['id' => $project1->id]);
}
public function test_tenant_cannot_access_another_tenants_resource(): void
{
$tenant1 = Tenant::factory()->create();
$tenant2 = Tenant::factory()->create();
$tenant1->makeCurrent();
$user1 = User::factory()->create();
$tenant2->makeCurrent();
$project2 = Project::factory()->create();
$tenant1->makeCurrent();
$this->actingAs($user1);
$response = $this->get("/api/projects/{$project2->id}");
$response->assertNotFound();
}
}
Monitoring and Observability
Track tenant-specific metrics to ensure quality service for all customers.
namespace App\Services;
use App\Models\Tenant;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
class TenantMetricsService
{
public function getDashboardMetrics(Tenant $tenant): array
{
return Cache::remember(
"tenant_{$tenant->id}_metrics",
300,
fn() => [
'total_users' => $tenant->users()->count(),
'active_projects' => $tenant->projects()->active()->count(),
'storage_used' => $this->getStorageUsed($tenant),
'api_calls_today' => $this->getApiCallsToday($tenant),
'database_size' => $this->getDatabaseSize($tenant),
]
);
}
protected function getDatabaseSize(Tenant $tenant): float
{
$result = DB::connection('tenant')
->selectOne("
SELECT
SUM(data_length + index_length) / 1024 / 1024 AS size_mb
FROM information_schema.tables
WHERE table_schema = ?
", [$tenant->database]);
return round($result->size_mb, 2);
}
protected function getApiCallsToday(Tenant $tenant): int
{
return Cache::get("tenant_{$tenant->id}_api_calls_" . now()->format('Y-m-d'), 0);
}
}
Real-World Case Study: Project Management SaaS
At NeedLaravelSite, we built a multi-tenant project management platform serving 500+ companies with the following architecture:
Challenge: Build a scalable SaaS platform where each company gets isolated data, custom branding, and flexible pricing tiers.
Solution:
- Architecture: Database-per-tenant approach for maximum isolation
- Tenant Identification: Domain-based routing with custom domain support
- Performance: Redis caching with tenant-specific prefixes
- Queue System: Laravel Horizon with tenant-aware jobs
- Monitoring: Custom tenant metrics dashboard
Results:
- Successfully onboarded 500+ tenants in 12 months
- 99.9% uptime across all tenants
- Average response time under 200ms
- Seamless scaling from 10 to 500 tenants without architecture changes
Key Learnings:
- Always test tenant isolation thoroughly
- Implement comprehensive monitoring from day one
- Design for scale, even if starting small
- Automate tenant provisioning completely
Conclusion
Building multi-tenant SaaS applications with Laravel 12 is more accessible than ever. Whether you choose single database, database-per-tenant, or schema-based approaches, Laravel provides the tools and flexibility you need.
Key takeaways:
Choose the right tenancy strategy based on your business requirements, budget, and compliance needs. Start with single database for MVPs, move to database-per-tenant for enterprise applications.
Security must be built-in from the start with global scopes, proper authorization policies, and thorough testing to prevent data leakage between tenants.
Performance optimization is critical for maintaining consistent user experience across all tenants through proper caching, query optimization, and resource monitoring.
Leverage proven packages like Spatie Laravel-Multitenancy to save development time and benefit from community-tested solutions.
Need Expert Help with Your Laravel SaaS?
At NeedLaravelSite, we specialize in building production-ready, scalable multi-tenant SaaS applications. With 8+ years of Laravel expertise and 100+ successful projects delivered, we can help you:
- Architect and build your multi-tenant Laravel application from scratch
- Migrate your existing application to a multi-tenant architecture
- Optimize performance and security for existing multi-tenant systems
- Conduct code audits and provide recommendations
Get in touch today and let's discuss how we can bring your SaaS vision to life with Laravel 12.