Building a Multi-Tenant SaaS with Laravel: Architecture and Implementation

Learn how to build a production-ready multi-tenant SaaS with Laravel and React. This guide covers separate vs shared DB strategies, tenant middleware, Sanctum auth, Stripe Cashier billing, feature flags, and onboarding flows with real code examples.

Building a Multi-Tenant SaaS with Laravel: Architecture and Implementation

By Elhassane Mehdioui, Full Stack Web Developer


Multi-tenancy is one of the most important architectural decisions you will make when building a SaaS product. Get it right early and scaling becomes straightforward. Get it wrong and you face painful, expensive migrations down the road. This Laravel multi-tenancy tutorial walks through every layer of the stack — from database strategy to React context — so you can ship a production-ready multi-tenant SaaS Laravel application with confidence.


What Is Multi-Tenancy and Why Does It Matter?

A multi-tenant SaaS application serves multiple customers (tenants) from a single deployed codebase. Each tenant sees only their own data, has their own configuration, and may be on a different billing plan. The alternative — deploying a separate instance per customer — does not scale economically.

The two dominant database strategies are separate databases per tenant and a shared database with a tenant_id column. Choosing between them is the first architectural decision.


Separate DB vs Shared DB with tenant_id

Separate Databases

Each tenant gets their own database. Laravel's database connection is switched at runtime.

Pros:

  • Complete data isolation — a bug in one tenant's query cannot leak data to another.
  • Easier compliance (GDPR, HIPAA) because data is physically separated.
  • Per-tenant backups and migrations are straightforward.

Cons:

  • Connection pool exhaustion at scale (thousands of tenants = thousands of potential connections).
  • Schema migrations must run across every tenant database.

Shared Database with tenant_id

Every table has a tenant_id foreign key. All tenants share one schema.

Pros:

  • Simple infrastructure — one database to back up and maintain.
  • Cross-tenant analytics are easy if you ever need them.

Cons:

  • A missing WHERE tenant_id = ? clause leaks data across tenants. This is a critical security risk.
  • Less suitable for regulated industries without additional controls.

For most early-stage SaaS products the shared database approach with a global scope is the pragmatic choice. The rest of this guide uses that strategy.


Setting Up the Tenant Model and Migration

php artisan make:model Tenant -m
// database/migrations/xxxx_create_tenants_table.php
Schema::create('tenants', function (Blueprint $table) {
    $table->id();
    $table->string('name');
    $table->string('slug')->unique();
    $table->string('plan')->default('starter'); // starter | growth | enterprise
    $table->json('feature_flags')->nullable();
    $table->timestamps();
});

Add tenant_id to every tenant-scoped table:

$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();

Tenant Middleware

The middleware resolves the current tenant from the request and stores it in a shared context. This is the heart of the multi-tenant SaaS Laravel architecture.

// app/Http/Middleware/ResolveTenant.php
namespace App\Http\Middleware;

use App\Models\Tenant;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;

class ResolveTenant
{
    public function handle(Request $request, Closure $next)
    {
        $slug = $request->header('X-Tenant') ?? $this->slugFromHost($request->getHost());

        $tenant = Tenant::where('slug', $slug)->firstOrFail();

        App::instance('tenant', $tenant);

        return $next($request);
    }

    private function slugFromHost(string $host): string
    {
        // acme.yoursaas.com -> acme
        return explode('.', $host)[0];
    }
}

Register it in bootstrap/app.php (Laravel 11) or app/Http/Kernel.php (Laravel 10):

->withMiddleware(function (Middleware $middleware) {
    $middleware->prependToGroup('api', \App\Http\Middleware\ResolveTenant::class);
})

Now anywhere in the application you can call app('tenant') to get the current tenant.


Global Scope for Automatic tenant_id Filtering

To prevent data leaks, attach a global Eloquent scope to every tenant-scoped model.

// app/Scopes/TenantScope.php
namespace App\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
    {
        $builder->where($model->getTable() . '.tenant_id', app('tenant')->id);
    }
}
// app/Traits/BelongsToTenant.php
namespace App\Traits;

use App\Scopes\TenantScope;

trait BelongsToTenant
{
    protected static function bootBelongsToTenant(): void
    {
        static::addGlobalScope(new TenantScope());

        static::creating(function ($model) {
            $model->tenant_id = app('tenant')->id;
        });
    }
}

Use the trait on every tenant-scoped model:

class Project extends Model
{
    use BelongsToTenant;
}

Every query on Project now automatically filters by the current tenant and auto-fills tenant_id on creation. The missing-WHERE data-leak risk is eliminated at the ORM layer.


Sanctum Per-Tenant Authentication

Laravel Sanctum is a great fit for SaaS Laravel React applications because it handles both SPA cookie auth and API token auth. For multi-tenancy, tokens must be scoped to a tenant so a token from tenant A cannot be used against tenant B's API.

// app/Http/Controllers/Auth/LoginController.php
public function login(Request $request)
{
    $request->validate(['email' => 'required|email', 'password' => 'required']);

    $tenant = app('tenant');

    $user = User::where('email', $request->email)
                ->where('tenant_id', $tenant->id)
                ->first();

    if (! $user || ! Hash::check($request->password, $user->password)) {
        throw ValidationException::withMessages(['email' => 'Invalid credentials.']);
    }

    $token = $user->createToken('sanctum', ['tenant:' . $tenant->slug]);

    return response()->json(['token' => $token->plainTextToken]);
}

Add a middleware that validates the token's abilities include the current tenant:

// app/Http/Middleware/ValidateTenantToken.php
public function handle(Request $request, Closure $next)
{
    if ($request->user() && ! $request->user()->tokenCan('tenant:' . app('tenant')->slug)) {
        abort(403, 'Token does not belong to this tenant.');
    }

    return $next($request);
}

React Context for Tenant Data

On the frontend, a React context holds the resolved tenant and the authenticated user so every component can access them without prop drilling.

// src/contexts/TenantContext.tsx
import React, { createContext, useContext, useEffect, useState } from 'react';

interface Tenant {
  id: number;
  slug: string;
  name: string;
  plan: 'starter' | 'growth' | 'enterprise';
  feature_flags: Record<string, boolean>;
}

interface TenantContextValue {
  tenant: Tenant | null;
  isLoading: boolean;
}

const TenantContext = createContext<TenantContextValue>({ tenant: null, isLoading: true });

export function TenantProvider({ children }: { children: React.ReactNode }) {
  const [tenant, setTenant] = useState<Tenant | null>(null);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    fetch('/api/tenant/current', {
      headers: { 'X-Tenant': window.location.hostname.split('.')[0] },
    })
      .then(r => r.json())
      .then(data => { setTenant(data); setIsLoading(false); });
  }, []);

  return (
    <TenantContext.Provider value={{ tenant, isLoading }}>
      {children}
    </TenantContext.Provider>
  );
}

export const useTenant = () => useContext(TenantContext);

Stripe Cashier Billing Per Tenant

For multi-tenant SaaS billing, the Tenant model — not the User model — is the Billable entity. Install Cashier:

composer require laravel/cashier
php artisan cashier:install

Add the Billable trait to the Tenant model:

use Laravel\Cashier\Billable;

class Tenant extends Model
{
    use Billable;
}

Create a subscription when a tenant signs up:

// app/Http/Controllers/BillingController.php
public function subscribe(Request $request)
{
    $tenant = app('tenant');
    $plan   = $request->input('plan'); // price_xxx from Stripe

    $tenant->newSubscription('default', $plan)
           ->create($request->input('payment_method'));

    $tenant->update(['plan' => $this->planNameFromPrice($plan)]);

    return response()->json(['status' => 'subscribed']);
}

public function portal(Request $request)
{
    return response()->json([
        'url' => app('tenant')->billingPortalUrl(route('dashboard')),
    ]);
}

Handle Stripe webhooks to keep the plan column in sync:

// routes/web.php
Route::stripeWebhooks('/stripe/webhook');
// app/Listeners/StripeEventListener.php
public function handle(WebhookReceived $event): void
{
    if ($event->payload['type'] === 'customer.subscription.updated') {
        $stripeCustomerId = $event->payload['data']['object']['customer'];
        $tenant = Tenant::where('stripe_id', $stripeCustomerId)->first();
        $tenant?->update(['plan' => $this->resolvePlan($event->payload)]);
    }
}

Feature Flags Per Plan

Feature flags let you gate functionality by plan without deploying new code.

// app/Services/FeatureService.php
namespace App\Services;

class FeatureService
{
    private array $planFeatures = [
        'starter'    => ['projects' => 3,  'api_access' => false, 'custom_domain' => false],
        'growth'     => ['projects' => 20, 'api_access' => true,  'custom_domain' => false],
        'enterprise' => ['projects' => -1, 'api_access' => true,  'custom_domain' => true],
    ];

    public function tenantCan(string $feature): bool
    {
        $tenant   = app('tenant');
        $overrides = $tenant->feature_flags ?? [];

        if (array_key_exists($feature, $overrides)) {
            return (bool) $overrides[$feature];
        }

        return (bool) ($this->planFeatures[$tenant->plan][$feature] ?? false);
    }
}

Register it as a singleton and create a Blade/React-friendly API endpoint:

// routes/api.php
Route::get('/features', fn () => response()->json(app(FeatureService::class)->all()));

In React, consume it from the tenant context:

const { tenant } = useTenant();
const canUseApi = tenant?.feature_flags?.api_access ?? false;

Onboarding Flow

A smooth onboarding flow is critical for SaaS activation. The flow should: create the tenant, create the first user, collect billing, and redirect to the dashboard.

// app/Http/Controllers/OnboardingController.php
public function store(Request $request)
{
    $data = $request->validate([
        'company'          => 'required|string|max:100',
        'email'            => 'required|email|unique:users',
        'password'         => 'required|min:8|confirmed',
        'plan'             => 'required|in:starter,growth,enterprise',
        'payment_method'   => 'required_unless:plan,starter',
    ]);

    DB::transaction(function () use ($data) {
        $tenant = Tenant::create([
            'name' => $data['company'],
            'slug' => Str::slug($data['company']) . '-' . Str::random(4),
            'plan' => $data['plan'],
        ]);

        $user = User::create([
            'tenant_id' => $tenant->id,
            'email'     => $data['email'],
            'password'  => Hash::make($data['password']),
            'role'      => 'owner',
        ]);

        if ($data['plan'] !== 'starter' && isset($data['payment_method'])) {
            $tenant->newSubscription('default', config("billing.prices.{$data['plan']}"))
                   ->create($data['payment_method']);
        }

        event(new TenantCreated($tenant, $user));
    });

    return response()->json(['redirect' => "https://{$tenant->slug}.yoursaas.com/dashboard"]);
}

The TenantCreated event can trigger welcome emails, seed default data, and notify your team in Slack — all asynchronously via queued listeners.


Deployment Considerations

  • Use wildcard DNS (*.yoursaas.com) pointing to your server so tenant subdomains resolve automatically.
  • Set SESSION_DOMAIN=.yoursaas.com in your .env for cross-subdomain cookie sharing.
  • Use a queue worker per tenant if job isolation is required, or prefix queue names with tenant_{id}.
  • Run php artisan migrate once — the shared schema means a single migration covers all tenants.

Conclusion

Building a multi-tenant SaaS with Laravel does not have to be intimidating. By choosing the right database strategy, enforcing tenant scopes at the ORM layer, scoping Sanctum tokens per tenant, and wiring up Stripe Cashier to the Tenant model, you get a solid foundation that scales. Layer in feature flags to de-risk plan gating, and a clean onboarding flow to drive activation, and you have everything you need to ship a production-grade SaaS Laravel React application.

The architecture described here has been battle-tested on real products. Start with the shared-database approach, add the global scope from day one, and you will avoid the most common multi-tenancy pitfalls.


Elhassane Mehdioui is a Full Stack Web Developer specialising in Laravel, React, and SaaS architecture.

Need the full picture?

Full-stack applications from database to UI.

MERN stack, Laravel + React, Spring Boot — complete solutions built end-to-end.

HassanOSSYS-00
SECURE CHANNEL · ACTIVE
INIT://