Building a Multi-Tenant SaaS App with Laravel: Practical Guide
Multi-tenancy means a single application instance serves multiple customers (tenants), with each tenant's data isolated from others. There are two main approaches: separate databases per tenant (clean isolation, expensive) or a single shared database with a tenant_id column on every table (cost-effective, requires disciplined scoping). This guide covers the single-database approach used in most production SaaS platforms.
Architecture Overview
- Every tenant-scoped table has a tenant_id foreign key
- A global scope automatically filters queries by the current tenant
- The current tenant is resolved from the request (subdomain, header, or JWT claim)
- Super-admin routes bypass tenant scoping entirely
Step 1: Tenant Model and Migration
// database/migrations/create_tenants_table.php
Schema::create('tenants', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('slug')->unique(); // used as subdomain
$table->string('domain')->nullable()->unique();
$table->json('settings')->nullable();
$table->enum('status', ['active', 'suspended', 'trial'])->default('trial');
$table->timestamp('trial_ends_at')->nullable();
$table->timestamps();
});Step 2: TenantScope Global Scope
The global scope automatically adds a WHERE tenant_id = ? to every Eloquent query on tenant-scoped models. This is the core safety mechanism — without it, tenants can see each other's data.
<?php
namespace App\Models\Scopes;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;
class TenantScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
if (app()->bound('current_tenant')) {
$builder->where($model->getTable() . '.tenant_id', app('current_tenant')->id);
}
}
}Step 3: BelongsToTenant Trait
<?php
namespace App\Models\Concerns;
use App\Models\Scopes\TenantScope;
trait BelongsToTenant
{
public static function bootBelongsToTenant(): void
{
static::addGlobalScope(new TenantScope());
static::creating(function ($model) {
if (app()->bound('current_tenant') && empty($model->tenant_id)) {
$model->tenant_id = app('current_tenant')->id;
}
});
}
public function tenant()
{
return $this->belongsTo(\App\Models\Tenant::class);
}
}
// Usage on any model:
class Project extends Model
{
use BelongsToTenant;
protected $fillable = ['tenant_id', 'name', 'description'];
}Step 4: Tenant Resolution Middleware
<?php
namespace App\Http\Middleware;
use App\Models\Tenant;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class ResolveTenant
{
public function handle(Request $request, Closure $next): Response
{
// Resolve by subdomain: tenant.yoursaas.com
$host = $request->getHost();
$parts = explode('.', $host);
$slug = count($parts) >= 3 ? $parts[0] : null;
// Or resolve by header for API clients:
// $slug = $request->header('X-Tenant');
if (!$slug) {
return response()->json(['message' => 'Tenant not found'], 404);
}
$tenant = Tenant::where('slug', $slug)->where('status', 'active')->first();
if (!$tenant) {
return response()->json(['message' => 'Tenant not found or suspended'], 404);
}
app()->instance('current_tenant', $tenant);
return $next($request);
}
}Step 5: Register Middleware
// bootstrap/app.php (Laravel 11)
->withMiddleware(function (Middleware $middleware) {
$middleware->alias([
'tenant' => \App\Http\Middleware\ResolveTenant::class,
]);
})
// routes/api.php
Route::middleware(['tenant', 'auth:sanctum'])->group(function () {
Route::apiResource('projects', ProjectController::class);
Route::apiResource('users', TenantUserController::class);
});
// Super-admin routes bypass tenant scoping
Route::middleware(['auth:sanctum', 'role:superadmin'])->prefix('admin')->group(function () {
Route::get('tenants', [AdminTenantController::class, 'index']);
});Step 6: Bypassing Scope for Admin Queries
// When you genuinely need cross-tenant data (admin reports, billing):
$allProjects = Project::withoutGlobalScope(TenantScope::class)->get();
// Or use a dedicated admin query scope:
$tenantProjects = Project::withoutGlobalScope(TenantScope::class)
->where('tenant_id', $specificTenantId)
->get();Critical Security Checklist
- Every table that holds tenant data must have a tenant_id column with a foreign key constraint
- Always use the BelongsToTenant trait on every tenant-scoped model — never forget it
- Write tests that attempt cross-tenant access and assert they return 0 results
- Be careful with raw DB queries — they bypass Eloquent scopes entirely
- File storage: prefix S3 keys with tenant_id/ to prevent cross-tenant file access
- Cache keys: prefix with tenant ID to prevent cache poisoning across tenants
Note: For serious multi-tenant SaaS, consider the stancl/tenancy package which automates much of this. But understanding the manual implementation first helps you debug issues and customise when the package doesn't fit.
Pratik Yewale
Assistant Manager – Software at Qing Aamby City Developers Corporations Limited. Previously Backend Developer at Neuromonk Infotech building scalable APIs, ERP systems, and booking platforms. Published researcher in cricket analytics.
View Portfolio