Advanced 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