Building a Multi-Tenant SaaS in Laravel 13: Complete Guide

calendar_today July 20, 2026
person info@softcrony.com
folder DevOps

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