Multi-tenancy is the architecture that makes SaaS possible — one application serving many customers, with their data completely isolated from each other. Laravel 13 makes this cleaner than ever with PHP Attributes, improved middleware, and better tooling.
This guide builds a complete multi-tenant foundation in Laravel 13 — the patterns we use at Softcrony for client SaaS projects.
Multi-Tenancy Strategies
There are three main approaches to multi-tenancy in Laravel:
| Strategy | How It Works | Best For | Complexity |
|---|---|---|---|
| Single DB, shared tables | company_id column on every table | Most SaaS apps | Low |
| Single DB, separate schemas | MySQL schema per tenant | Mid-scale, stronger isolation | Medium |
| Separate databases | One database per tenant | Enterprise, regulated industries | High |
This guide implements Strategy 1 — Single DB with shared tables. It handles 95% of SaaS use cases and is the simplest to build and maintain.
Step 1 — Database Architecture
php artisan make:migration create_tenants_table
php artisan make:migration create_tenant_users_table
<?php
// Tenants (companies/organizations)
Schema::create('tenants', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('slug')->unique(); // used for subdomain
$table->string('domain')->nullable()->unique(); // custom domain
$table->string('plan')->default('free'); // free/starter/pro/enterprise
$table->string('status')->default('active'); // active/suspended/cancelled
$table->json('settings')->nullable();
$table->timestamp('trial_ends_at')->nullable();
$table->timestamps();
$table->softDeletes();
});
// Users belong to tenants
Schema::create('tenant_users', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->onDelete('cascade');
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->string('role')->default('member'); // owner/admin/member
$table->timestamps();
$table->unique(['tenant_id', 'user_id']);
});
// All your feature tables need tenant_id
Schema::create('projects', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->onDelete('cascade');
$table->foreignId('user_id')->constrained(); // creator
$table->string('name');
$table->text('description')->nullable();
$table->string('status')->default('active');
$table->timestamps();
$table->softDeletes();
$table->index('tenant_id'); // Always index tenant_id
});
Step 2 — Tenant Model with Laravel 13 Attributes
php artisan make:model Tenant
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Table;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
#[Table('tenants')]
#[Fillable(['name', 'slug', 'domain', 'plan', 'status', 'settings', 'trial_ends_at'])]
class Tenant extends Model
{
use SoftDeletes;
protected $casts = [
'settings' => 'array',
'trial_ends_at' => 'datetime',
];
public function users(): BelongsToMany
{
return $this->belongsToMany(User::class, 'tenant_users')
->withPivot('role')
->withTimestamps();
}
public function projects(): HasMany
{
return $this->hasMany(Project::class);
}
public function isOnTrial(): bool
{
return $this->trial_ends_at && $this->trial_ends_at->isFuture();
}
public function hasFeature(string $feature): bool
{
return in_array($feature, config("plans.{$this->plan}.features", []));
}
}
Step 3 — Tenant Resolution
The most important part — identifying which tenant owns the current request:
php artisan make:class Services/TenantResolver
<?php
namespace App\Services;
use App\Models\Tenant;
use Illuminate\Http\Request;
class TenantResolver
{
public function fromRequest(Request $request): ?Tenant
{
// 1. Try subdomain: company.yourapp.com
$host = $request->getHost();
$appDomain = config('app.domain', 'yourapp.com');
if (str_ends_with($host, '.' . $appDomain)) {
$slug = str_replace('.' . $appDomain, '', $host);
return Tenant::where('slug', $slug)->where('status', 'active')->first();
}
// 2. Try custom domain
return Tenant::where('domain', $host)->where('status', 'active')->first();
}
public function fromUser(): ?Tenant
{
// Get tenant from authenticated user's context
return auth()->user()?->currentTenant();
}
}
Step 4 — Tenant Context (Singleton)
php artisan make:class TenantContext
<?php
namespace App;
use App\Models\Tenant;
class TenantContext
{
private static ?Tenant $current = null;
public static function set(Tenant $tenant): void
{
static::$current = $tenant;
}
public static function get(): ?Tenant
{
return static::$current;
}
public static function id(): ?int
{
return static::$current?->id;
}
public static function clear(): void
{
static::$current = null;
}
public static function require(): Tenant
{
if (!static::$current) {
throw new \RuntimeException('No tenant context set');
}
return static::$current;
}
}
Step 5 — Tenant Middleware
php artisan make:middleware InitializeTenant
<?php
namespace App\Http\Middleware;
use App\TenantContext;
use App\Services\TenantResolver;
use Closure;
use Illuminate\Http\Request;
class InitializeTenant
{
public function __construct(
private readonly TenantResolver $resolver
) {}
public function handle(Request $request, Closure $next)
{
$tenant = $this->resolver->fromRequest($request);
if (!$tenant) {
abort(404, 'Tenant not found');
}
// Set tenant context for this request
TenantContext::set($tenant);
// Make tenant available in views
view()->share('tenant', $tenant);
return $next($request);
}
}
Register in bootstrap/app.php:
->withMiddleware(function (Middleware $middleware) {
$middleware->alias([
'tenant' => \App\Http\Middleware\InitializeTenant::class,
]);
})
Step 6 — Tenant Global Scope
This is the core security layer — automatically filtering all queries by the current tenant:
<?php
namespace App\Models\Scopes;
use App\TenantContext;
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
{
$tenantId = TenantContext::id();
if ($tenantId) {
$builder->where($model->getTable() . '.tenant_id', $tenantId);
}
}
}
// Trait to add to all tenant-scoped models
trait BelongsToTenant
{
protected static function bootBelongsToTenant(): void
{
// Apply scope on all queries
static::addGlobalScope(new TenantScope());
// Auto-set tenant_id on create
static::creating(function (Model $model) {
if (!$model->tenant_id && TenantContext::id()) {
$model->tenant_id = TenantContext::id();
}
});
}
}
Use the trait on all tenant-scoped models:
<?php
namespace App\Models;
use App\Models\Scopes\BelongsToTenant;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
#[Fillable(['name', 'description', 'status', 'tenant_id', 'user_id'])]
class Project extends Model
{
use BelongsToTenant;
// All queries automatically scoped to current tenant
// tenant_id automatically set on create
}
Step 7 — Subdomain Routing
// routes/web.php
Route::domain('{tenant}.' . config('app.domain'))
->middleware(['tenant', 'auth'])
->group(function () {
Route::get('/dashboard', [DashboardController::class, 'index'])->name('dashboard');
Route::resource('projects', ProjectController::class);
Route::resource('tasks', TaskController::class);
});
Step 8 — Tenant Registration Flow
<?php
namespace App\Http\Controllers;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
class TenantRegistrationController extends Controller
{
public function store(Request $request)
{
$validated = $request->validate([
'company_name' => 'required|string|max:255',
'name' => 'required|string|max:255',
'email' => 'required|email|unique:users',
'password' => 'required|min:8|confirmed',
]);
return DB::transaction(function () use ($validated) {
// Create tenant
$tenant = Tenant::create([
'name' => $validated['company_name'],
'slug' => Str::slug($validated['company_name']),
'plan' => 'free',
'trial_ends_at' => now()->addDays(14),
]);
// Create owner user
$user = User::create([
'name' => $validated['name'],
'email' => $validated['email'],
'password' => Hash::make($validated['password']),
]);
// Attach user to tenant as owner
$tenant->users()->attach($user->id, ['role' => 'owner']);
// Seed default data for the new tenant
app(TenantSeeder::class)->run($tenant);
return redirect("https://{$tenant->slug}." . config('app.domain') . '/dashboard');
});
}
}
Step 9 — Plan and Feature Management
// config/plans.php
return [
'free' => [
'name' => 'Free',
'price' => 0,
'features' => ['projects:5', 'users:2', 'storage:1gb'],
],
'starter' => [
'name' => 'Starter',
'price' => 999, // ₹999/month
'features' => ['projects:25', 'users:10', 'storage:10gb', 'api_access'],
],
'pro' => [
'name' => 'Pro',
'price' => 2999,
'features' => ['projects:unlimited', 'users:unlimited', 'storage:100gb', 'api_access', 'priority_support'],
],
];
// Middleware to check plan features
class RequireFeature
{
public function handle(Request $request, Closure $next, string $feature)
{
$tenant = TenantContext::require();
if (!$tenant->hasFeature($feature)) {
return response()->json([
'error' => 'upgrade_required',
'message' => 'This feature requires a higher plan.',
'upgrade' => route('billing.upgrade'),
], 403);
}
return $next($request);
}
}
// Usage in routes
Route::middleware(['tenant', 'auth', 'feature:api_access'])->group(function () {
Route::apiResource('api/v1/projects', ApiProjectController::class);
});
Step 10 — Razorpay Subscription Billing
composer require razorpay/razorpay
<?php
namespace App\Services;
use App\Models\Tenant;
use Razorpay\Api\Api;
class BillingService
{
private Api $razorpay;
public function __construct()
{
$this->razorpay = new Api(
config('services.razorpay.key'),
config('services.razorpay.secret')
);
}
public function createSubscription(Tenant $tenant, string $plan): array
{
$planConfig = config("plans.{$plan}");
// Create Razorpay plan if not exists
$razorpayPlan = $this->razorpay->plan->create([
'period' => 'monthly',
'interval' => 1,
'item' => [
'name' => $planConfig['name'] . ' Plan',
'amount' => $planConfig['price'] * 100, // paise
'currency' => 'INR',
],
]);
// Create subscription
$subscription = $this->razorpay->subscription->create([
'plan_id' => $razorpayPlan->id,
'total_count' => 12, // 12 months
'customer_notify' => 1,
'notes' => ['tenant_id' => $tenant->id],
]);
$tenant->update([
'plan' => $plan,
'razorpay_subscription_id' => $subscription->id,
]);
return ['subscription_id' => $subscription->id];
}
}
Security Checklist for Multi-Tenant Apps
| Check | Implementation |
|---|---|
| All tenant tables have tenant_id index | Migration |
| Global scope on all tenant models | BelongsToTenant trait |
| Tenant middleware on all tenant routes | Route middleware |
| File uploads scoped to tenant folder | Storage::disk()->put(“tenant_{id}/…”) |
| Redis cache keys prefixed with tenant_id | Cache::prefix(“tenant_{id}”) |
| Queued jobs include tenant_id context | Job middleware |
| API rate limits per tenant | RateLimiter::for() |
| Cross-tenant query test in test suite | PHPUnit feature test |
If you’re planning to build a SaaS product and need a solid multi-tenant foundation, our Laravel team at Softcrony can architect and build it for you.
Leave a comment