Multi-Tenancy Patterns in Laravel: Building SaaS Applications

· 6 min read

Multi-tenancy allows a single application to serve multiple customers (tenants) while keeping their data isolated. This is essential for SaaS applications that need to scale efficiently.

Identifying Tenants

The first step is determining how to identify which tenant a request belongs to.

Option 1: Domain-Based

Each tenant gets their own domain:

  • acme.yourapp.com
  • widgets.yourapp.com
// app/Services/TenantResolver.php
class TenantResolver
{
    public static function getTenant(): ?Tenant
    {
        $domain = request()->getHost();
        
        return Tenant::where('domain', $domain)->first();
    }
}

Option 2: Subdomain-Based

Extract subdomain from a single domain:

public static function getTenant(): ?Tenant
{
    $subdomain = explode('.', request()->getHost())[0];
    
    return Tenant::where('subdomain', $subdomain)->first();
}

Option 3: Path-Based

Use route parameter:

// routes/web.php
Route::prefix('{tenant}')->group(function () {
    Route::get('/dashboard', [DashboardController::class, 'show']);
});

// Controller
public function show(Tenant $tenant)
{
    return view('dashboard', ['tenant' => $tenant]);
}

Option 4: Header-Based (API)

Use API token or header:

public static function getTenant(): ?Tenant
{
    $token = request()->bearerToken();
    
    return Tenant::whereHas('apiTokens', function ($query) use ($token) {
        $query->where('token', $token);
    })->first();
}

Tenant Middleware

Create middleware to resolve and validate tenants:

namespace App\Http\Middleware;

use App\Services\TenantResolver;
use Closure;

class ResolveTenant
{
    public function handle($request, Closure $next)
    {
        $tenant = TenantResolver::getTenant();

        if (!$tenant) {
            abort(404, 'Tenant not found');
        }

        // Make tenant available globally
        app()->instance('tenant', $tenant);
        
        // Bind in container
        app()->bind(Tenant::class, fn() => $tenant);

        return $next($request);
    }
}

Register in App\Http\Kernel.php:

protected $middlewareGroups = [
    'web' => [
        // ...
        \App\Http\Middleware\ResolveTenant::class,
    ],
];

Database Isolation

Single Database with Tenant Column

Most common approach:

Schema::create('posts', function (Blueprint $table) {
    $table->id();
    $table->foreignId('tenant_id')->constrained();
    $table->string('title');
    $table->longText('content');
    $table->timestamps();
});

Query scoping:

class Post extends Model
{
    protected static function booted()
    {
        static::addGlobalScope('tenant', function (Builder $query) {
            if (app()->bound('tenant')) {
                $query->where('tenant_id', app('tenant')->id);
            }
        });
    }
}

Separate Tenant Databases

For complete isolation:

// config/database.php
'connections' => [
    'tenant' => [
        'driver' => 'mysql',
        'host' => env('DB_HOST'),
        'database' => 'tenant_' . app('tenant')->id,
        'username' => env('DB_USERNAME'),
        'password' => env('DB_PASSWORD'),
    ],
],

Use in models:

class Post extends Model
{
    protected $connection = 'tenant';
}

Migrate for each tenant:

public function migrate(Tenant $tenant)
{
    Artisan::call('migrate', [
        '--database' => 'tenant_' . $tenant->id,
    ]);
}

Service Class Pattern

Create tenant-aware services:

namespace App\Services;

use App\Models\Tenant;

class PostService
{
    public function __construct(
        private Tenant $tenant,
    ) {}

    public function getPosts()
    {
        return Post::where('tenant_id', $this->tenant->id)->get();
    }

    public function createPost(array $data)
    {
        return Post::create([
            'tenant_id' => $this->tenant->id,
            ...$data,
        ]);
    }
}

// In controller
public function store(Request $request)
{
    $service = new PostService(app('tenant'));
    $post = $service->createPost($request->validated());

    return redirect()->route('posts.show', $post);
}

Or use service container binding:

// AppServiceProvider
public function register()
{
    $this->app->bind(PostService::class, function () {
        return new PostService(app('tenant'));
    });
}

Feature Flags per Tenant

Different tenants may have different features:

class Tenant extends Model
{
    public function hasFeature(string $feature): bool
    {
        return $this->features->contains('name', $feature);
    }

    public function enableFeature(string $feature)
    {
        $this->features()->attach(Feature::where('name', $feature)->first());
    }
}

// In controller
if (auth()->user()->tenant->hasFeature('comments')) {
    // Show comments feature
}

Tenant Seeding

Seed each tenant's data:

// database/seeders/TenantSeeder.php
class TenantSeeder extends Seeder
{
    public function run(Tenant $tenant)
    {
        User::factory(5)->for($tenant)->create();
        Post::factory(20)->for($tenant)->create();
    }
}

// Create command
public function handle()
{
    $tenant = Tenant::create([
        'name' => 'ACME Corp',
        'domain' => 'acme.localhost',
    ]);

    $this->call(TenantSeeder::class, parameters: ['tenant' => $tenant]);
}

Testing Multi-Tenancy

use Illuminate\Foundation\Testing\RefreshDatabase;

class PostTest extends TestCase
{
    use RefreshDatabase;

    protected function setUp(): void
    {
        parent::setUp();
        
        $this->tenant = Tenant::factory()->create();
        $this->actingAsTenanant($this->tenant);
    }

    protected function actingAsTenant(Tenant $tenant)
    {
        app()->instance('tenant', $tenant);
        $this->be(User::factory()->for($tenant)->create());
        
        return $this;
    }

    public function test_can_create_post()
    {
        $response = $this->post('/posts', [
            'title' => 'Test Post',
            'content' => 'Content',
        ]);

        $this->assertDatabaseHas('posts', [
            'tenant_id' => $this->tenant->id,
            'title' => 'Test Post',
        ]);
    }

    public function test_cannot_access_other_tenant_posts()
    {
        $otherTenant = Tenant::factory()->create();
        $post = Post::factory()->for($otherTenant)->create();

        $response = $this->get("/posts/{$post->id}");

        $response->assertNotFound();
    }
}

Tenant Context Helper

Create a helper to manage tenant context:

namespace App\Support;

class TenantContext
{
    private static ?Tenant $current = null;

    public static function set(Tenant $tenant): void
    {
        self::$current = $tenant;
        app()->instance('tenant', $tenant);
    }

    public static function get(): ?Tenant
    {
        return self::$current ?? app('tenant');
    }

    public static function id(): ?int
    {
        return self::get()?->id;
    }

    public static function isSet(): bool
    {
        return self::$current !== null;
    }
}

// Usage
TenantContext::set($tenant);
$posts = Post::where('tenant_id', TenantContext::id())->get();

Billing & Subscriptions per Tenant

Track usage per tenant:

// database/migrations/xxxx_create_tenant_subscriptions_table.php
Schema::create('tenant_subscriptions', function (Blueprint $table) {
    $table->id();
    $table->foreignId('tenant_id')->constrained();
    $table->string('plan');
    $table->integer('max_users');
    $table->integer('max_storage'); // MB
    $table->timestamp('renews_at');
    $table->timestamps();
});

Usage tracking:

class Tenant extends Model
{
    public function getUserCount(): int
    {
        return User::where('tenant_id', $this->id)->count();
    }

    public function getStorageUsed(): int
    {
        // Calculate storage usage
    }

    public function isWithinLimits(): bool
    {
        return $this->getUserCount() <= $this->subscription->max_users
            && $this->getStorageUsed() <= $this->subscription->max_storage;
    }
}

Best Practices

  1. Always scope queries - Use global scopes
  2. Validate tenant access - Check user belongs to tenant
  3. Isolate data - Use tenant_id columns or separate databases
  4. Test thoroughly - Test multi-tenant scenarios
  5. Handle migrations - Run for each tenant separately
  6. Monitor per-tenant - Track usage and performance
  7. Plan for growth - Design for scalability

Common Architectures

Lightweight SaaS

  • Single shared database
  • Tenant column on all tables
  • Global scopes for automatic filtering

Enterprise SaaS

  • Separate database per tenant
  • Complete data isolation
  • Tenant-specific features and customizations

Hybrid

  • Shared database for core data
  • Separate database for sensitive data
  • Configurable per tenant

Summary

Multi-tenancy patterns enable efficient SaaS applications:

  • Identify tenants - Domain, subdomain, path, or header
  • Middleware - Resolve and validate tenants per request
  • Data isolation - Tenant columns or separate databases
  • Service classes - Encapsulate tenant logic
  • Feature flags - Different features per tenant
  • Testing - Verify tenant isolation

Choose the right approach for your SaaS model.

Comments