Advanced patterns

Multi-Tenancy Patterns

This guide covers multi-tenant architecture patterns with Laravolt v7, including tenant isolation strategies and implementation examples.

Overview

Multi-tenancy allows a single application instance to serve multiple customers (tenants) while keeping their data isolated and secure.

When to Use Multi-Tenancy:

  • SaaS applications serving multiple organizations
  • White-label platforms with customer-specific branding
  • Enterprise applications with department-level isolation
  • Marketplace platforms with vendor separation

Trade-offs:

  • ✅ Lower infrastructure costs (shared resources)
  • ✅ Easier maintenance (single codebase)
  • ✅ Faster feature deployment (all tenants updated simultaneously)
  • ⚠️ More complex data isolation logic
  • ⚠️ Potential performance impact from noisy neighbors
  • ⚠️ Higher security requirements

Tenant Isolation Strategies

Strategy 1: Database Per Tenant

Description: Each tenant gets a dedicated database.

Pros:

  • Complete data isolation
  • Easy backup/restore per tenant
  • Independent scaling
  • Simpler queries (no tenant filtering)

Cons:

  • Higher infrastructure costs
  • Complex connection management
  • Difficult cross-tenant reporting
  • Migration overhead (N databases)

When to Use:

  • Enterprise customers requiring data residency
  • High-value tenants with custom requirements
  • Strict compliance requirements (HIPAA, SOC2)

Strategy 2: Shared Database with Tenant Column

Description: Single database with tenant_id column on all tables.

Pros:

  • Lower infrastructure costs
  • Easy cross-tenant analytics
  • Simple connection management
  • Efficient resource utilization

Cons:

  • Risk of data leakage if queries miss tenant filter
  • Complex query scoping
  • Shared resource contention
  • Backup/restore affects all tenants

When to Use:

  • SMB SaaS with many small tenants
  • Cost-sensitive applications
  • Tenants with similar data volumes

Strategy 3: Schema Per Tenant (PostgreSQL)

Description: Single database with separate schema per tenant.

Pros:

  • Good data isolation
  • Moderate infrastructure costs
  • Easier than database-per-tenant
  • Schema-level permissions

Cons:

  • PostgreSQL-specific
  • Connection pooling complexity
  • Migration overhead (N schemas)

When to Use:

  • PostgreSQL-based applications
  • Medium-sized tenant base (10-100 tenants)
  • Balance between isolation and cost

Implementation: Shared Database Strategy

This is the most common pattern for Laravel/Laravolt applications.

Step 1: Database Schema

SQL
-- Tenants table
CREATE TABLE tenants (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(255) NOT NULL,
slug VARCHAR(255) UNIQUE NOT NULL,
domain VARCHAR(255) UNIQUE,
database VARCHAR(255),
created_at TIMESTAMP,
updated_at TIMESTAMP,
INDEX idx_slug (slug),
INDEX idx_domain (domain)
);
-- Example: Products table with tenant_id
CREATE TABLE products (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
tenant_id BIGINT UNSIGNED NOT NULL,
name VARCHAR(255) NOT NULL,
sku VARCHAR(100) NOT NULL,
price DECIMAL(10,2) NOT NULL,
created_at TIMESTAMP,
updated_at TIMESTAMP,
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE,
INDEX idx_tenant (tenant_id),
UNIQUE KEY unique_sku_per_tenant (tenant_id, sku)
);

Step 2: Tenant Model

PHP
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Tenant extends Model
{
protected $fillable = [
'name',
'slug',
'domain',
'database',
];
public function products(): HasMany
{
return $this->hasMany(Product::class);
}
public function users(): HasMany
{
return $this->hasMany(User::class);
}
public function scopeByDomain($query, string $domain)
{
return $query->where('domain', $domain);
}
public function scopeBySlug($query, string $slug)
{
return $query->where('slug', $slug);
}
}

Step 3: Tenant-Aware Base Model

PHP
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
abstract class TenantModel extends Model
{
protected static function booted()
{
// Auto-scope all queries to current tenant
static::addGlobalScope('tenant', function (Builder $builder) {
if ($tenantId = self::getCurrentTenantId()) {
$builder->where('tenant_id', $tenantId);
}
});
// Auto-set tenant_id on creation
static::creating(function ($model) {
if (!$model->tenant_id && $tenantId = self::getCurrentTenantId()) {
$model->tenant_id = $tenantId;
}
});
}
protected static function getCurrentTenantId(): ?int
{
return Auth::user()?->tenant_id ?? session('tenant_id');
}
public function tenant()
{
return $this->belongsTo(Tenant::class);
}
}

Step 4: Product Model (Tenant-Aware)

PHP
<?php
namespace App\Domains\Catalog\Models;
use App\Models\TenantModel;
class Product extends TenantModel
{
protected $fillable = [
'tenant_id',
'name',
'sku',
'price',
'description',
];
protected $casts = [
'price' => 'decimal:2',
];
// Queries automatically scoped to current tenant
// Product::all() → only current tenant's products
// Product::find(1) → only if belongs to current tenant
}

Step 5: Tenant Identification Middleware

PHP
<?php
namespace App\Http\Middleware;
use Closure;
use App\Models\Tenant;
use Illuminate\Http\Request;
class IdentifyTenant
{
public function handle(Request $request, Closure $next)
{
$tenant = $this->identifyTenant($request);
if (!$tenant) {
abort(404, 'Tenant not found');
}
// Store tenant in session
session(['tenant_id' => $tenant->id]);
// Share with views
view()->share('currentTenant', $tenant);
return $next($request);
}
protected function identifyTenant(Request $request): ?Tenant
{
// Strategy 1: Subdomain (tenant1.app.com)
if ($subdomain = $this->getSubdomain($request)) {
return Tenant::bySlug($subdomain)->first();
}
// Strategy 2: Custom domain (tenant1.com)
if ($domain = $request->getHost()) {
return Tenant::byDomain($domain)->first();
}
// Strategy 3: Path prefix (/tenant1/...)
if ($slug = $request->segment(1)) {
return Tenant::bySlug($slug)->first();
}
return null;
}
protected function getSubdomain(Request $request): ?string
{
$host = $request->getHost();
$parts = explode('.', $host);
// tenant1.app.com → tenant1
if (count($parts) >= 3) {
return $parts[0];
}
return null;
}
}

Register in app/Http/Kernel.php:

PHP
protected $middlewareGroups = [
'web' => [
// ... other middleware
\App\Http\Middleware\IdentifyTenant::class,
],
];

Thunderclap in Multi-Tenant Context

Generate Tenant-Aware CRUD

Bash
# 1. Generate base CRUD
php artisan thunderclap:generate Product \
--fields="name:string,sku:string,price:decimal" \
--with-tenant
# 2. Thunderclap adds tenant_id to migration

Generated migration includes:

PHP
Schema::create('products', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->string('name');
$table->string('sku');
$table->decimal('price', 10, 2);
$table->timestamps();
$table->unique(['tenant_id', 'sku']);
$table->index('tenant_id');
});

Generated model extends TenantModel:

PHP
class Product extends TenantModel
{
// Automatically tenant-scoped
}

Complete Example: Multi-Tenant Product Management

Scenario

SaaS platform where each company manages their own product catalog.

Implementation

1. Tenant Setup

PHP
// Database seeder
$acme = Tenant::create([
'name' => 'Acme Corp',
'slug' => 'acme',
'domain' => 'acme.myapp.com',
]);
$globex = Tenant::create([
'name' => 'Globex Inc',
'slug' => 'globex',
'domain' => 'globex.myapp.com',
]);

2. User Assignment

PHP
// Users table migration
Schema::table('users', function (Blueprint $table) {
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
});
// User model
class User extends Authenticatable
{
public function tenant()
{
return $this->belongsTo(Tenant::class);
}
}

3. Controller (Automatically Tenant-Scoped)

PHP
class ProductController extends Controller
{
public function index()
{
// Only returns current tenant's products
$products = Product::with('category')->paginate(20);
return view('products.index', compact('products'));
}
public function store(StoreProductRequest $request)
{
// tenant_id automatically set by TenantModel
$product = Product::create($request->validated());
return redirect()->route('products.show', $product);
}
}

4. Testing Tenant Isolation

PHP
use Tests\TestCase;
use App\Models\Tenant;
use App\Models\Product;
class TenantIsolationTest extends TestCase
{
public function test_users_only_see_their_tenant_products()
{
$acme = Tenant::factory()->create(['slug' => 'acme']);
$globex = Tenant::factory()->create(['slug' => 'globex']);
$acmeProduct = Product::factory()->create([
'tenant_id' => $acme->id,
'name' => 'Acme Widget',
]);
$globexProduct = Product::factory()->create([
'tenant_id' => $globex->id,
'name' => 'Globex Gadget',
]);
// Simulate Acme user
session(['tenant_id' => $acme->id]);
$products = Product::all();
$this->assertCount(1, $products);
$this->assertEquals('Acme Widget', $products->first()->name);
}
public function test_cannot_access_other_tenant_product()
{
$acme = Tenant::factory()->create();
$globex = Tenant::factory()->create();
$globexProduct = Product::factory()->create(['tenant_id' => $globex->id]);
// Simulate Acme user
session(['tenant_id' => $acme->id]);
$product = Product::find($globexProduct->id);
$this->assertNull($product); // Global scope filters it out
}
}

Security Considerations

1. Always Use Global Scopes

PHP
// ❌ BAD: Bypasses tenant scope
DB::table('products')->where('id', $id)->first();
// ✅ GOOD: Respects tenant scope
Product::find($id);

2. Validate Tenant Ownership in Policies

PHP
class ProductPolicy
{
public function update(User $user, Product $product): bool
{
// Double-check tenant ownership
return $user->tenant_id === $product->tenant_id;
}
}

3. Prevent Tenant ID Tampering

PHP
// ❌ BAD: Allows tenant_id in request
public function store(Request $request)
{
Product::create($request->all()); // User could inject tenant_id!
}
// ✅ GOOD: Exclude tenant_id from mass assignment
protected $guarded = ['tenant_id'];
// Or explicitly set it
public function store(Request $request)
{
$product = new Product($request->validated());
$product->tenant_id = auth()->user()->tenant_id;
$product->save();
}

4. Test Tenant Isolation

Bash
# Run tenant isolation test suite
php artisan test --filter=TenantIsolation

Performance Optimization

1. Index tenant_id Columns

PHP
$table->index('tenant_id');
$table->index(['tenant_id', 'created_at']); // For date-filtered queries

2. Partition Large Tables (MySQL 8.0+)

SQL
ALTER TABLE products
PARTITION BY HASH(tenant_id)
PARTITIONS 10;

3. Cache Per Tenant

PHP
Cache::tags(['tenant:' . $tenantId, 'products'])->remember('products.list', 3600, function () {
return Product::all();
});

Migration from Single-Tenant

Step 1: Add tenant_id to Existing Tables

PHP
Schema::table('products', function (Blueprint $table) {
$table->foreignId('tenant_id')->nullable()->constrained();
$table->index('tenant_id');
});

Step 2: Backfill tenant_id

PHP
// Create default tenant
$defaultTenant = Tenant::create(['name' => 'Default', 'slug' => 'default']);
// Assign all existing records
DB::table('products')->update(['tenant_id' => $defaultTenant->id]);
DB::table('users')->update(['tenant_id' => $defaultTenant->id]);

Step 3: Make tenant_id Required

PHP
Schema::table('products', function (Blueprint $table) {
$table->foreignId('tenant_id')->nullable(false)->change();
});

Resources


Next Steps:

Previous
Common issues