Multi-Tenancy Patterns in Laravel: Building SaaS Applications
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.comwidgets.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
- Always scope queries - Use global scopes
- Validate tenant access - Check user belongs to tenant
- Isolate data - Use tenant_id columns or separate databases
- Test thoroughly - Test multi-tenant scenarios
- Handle migrations - Run for each tenant separately
- Monitor per-tenant - Track usage and performance
- 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.