Skip to content
Blog

30 October 2026 · 1 min read

How to build a multi-tenant SaaS in Laravel (step by step)

TL;DR

Goal: turn a fresh Laravel app into a multi-tenant SaaS: each customer (tenant) gets its own subdomain and data, paid plans run on Stripe, and tests prove tenants can never see each other's data.

Tenants

acme.yourapp.test, globex.yourapp.test

One database

tenant_id on every tenant-owned table.

Billing

Laravel Cashier + Stripe.

Isolation tests

Pest tests that fail on leaks.

01How a request flows

Request

acme.yourapp.test

Middleware

find tenant by subdomain

Bind tenant

app container

Global scope

where tenant_id = ?

Response

only Acme data

02Pick a tenancy model

Single DB + tenant_idDatabase per tenant
Setup effortLowHigher (provisioning, migrations per DB)
IsolationLogical (scopes + tests)Physical
Cross-tenant reportingEasyHarder
Good forMost B2B SaaS starting outStrict compliance needs

This guide uses single database + tenant_id. For database-per-tenant, look at a package such as stancl/tenancy.

03Prerequisites

  1. 1

    PHP 8.2+ and Composer

    Check the PHP version your Laravel release requires.

  2. 2

    A database

    MySQL or PostgreSQL.

  3. 3

    Stripe test account

    For the test-mode keys.

  4. 4

    Local subdomains

    Laravel Herd/Valet, or entries in your hosts file.

04Create the app and the tenants table

composer create-project laravel/laravel saas
cd saas
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('subdomain')->unique();
    $table->timestamps();
});

// add to the users migration (and every tenant-owned table)
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();

05Resolve the tenant from the subdomain

php artisan make:middleware IdentifyTenant
// app/Http/Middleware/IdentifyTenant.php
public function handle(Request $request, Closure $next): Response
{
    $sub = explode('.', $request->getHost())[0];
    $tenant = Tenant::where('subdomain', $sub)->firstOrFail();

    app()->instance(Tenant::class, $tenant);

    return $next($request);
}
// routes/web.php
Route::domain('{tenant}.' . config('app.domain'))
    ->middleware(['web', IdentifyTenant::class])
    ->group(function () {
        Route::get('/', DashboardController::class);
    });

Add 'domain' => env('APP_DOMAIN') to config/app.php and set APP_DOMAIN=yourapp.test.

06Scope every query to the tenant

// app/Models/Concerns/BelongsToTenant.php
trait BelongsToTenant
{
    protected static function bootBelongsToTenant(): void
    {
        static::addGlobalScope('tenant', function (Builder $query) {
            if (app()->bound(Tenant::class)) {
                $query->where($query->getModel()->getTable() . '.tenant_id', app(Tenant::class)->id);
            }
        });

        static::creating(function (Model $model) {
            if (app()->bound(Tenant::class) && ! $model->tenant_id) {
                $model->tenant_id = app(Tenant::class)->id;
            }
        });
    }
}

Add use BelongsToTenant; to every tenant-owned model (Project, Invoice...).

DEEP DIVEQueued jobs need the tenant too

Jobs run outside the request, so the tenant isn't bound. Pass the tenant id into the job and bind it at the start of handle():

public function __construct(public int $tenantId) {}

public function handle(): void
{
    app()->instance(Tenant::class, Tenant::findOrFail($this->tenantId));
    // ... tenant-scoped work
}

07Add Stripe billing per tenant

composer require laravel/cashier
php artisan vendor:publish --tag="cashier-migrations"
php artisan migrate
// The tenant pays, not the user:
use Laravel\Cashier\Billable;
class Tenant extends Model { use Billable; }

// app/Providers/AppServiceProvider.php -> boot()
Cashier::useCustomerModel(Tenant::class);

The published migrations target users; edit them to add the billing columns to tenants and point subscriptions at the tenant before migrating.

// start a subscription (Stripe Checkout)
return $tenant->newSubscription('default', 'price_XXXX')
    ->checkout(['success_url' => route('billing.done'), 'cancel_url' => route('billing')]);

Put STRIPE_KEY, STRIPE_SECRET and STRIPE_WEBHOOK_SECRET in .env and register the webhook at /stripe/webhook.

08Prove isolation with a test

// tests/Feature/TenantIsolationTest.php (Pest)
it('never shows another tenant\'s projects', function () {
    $acme = Tenant::factory()->create(['subdomain' => 'acme']);
    $globex = Tenant::factory()->create(['subdomain' => 'globex']);
    Project::factory()->for($globex)->create(['name' => 'Secret']);

    app()->instance(Tenant::class, $acme);

    expect(Project::count())->toBe(0);
});

Write one of these for every tenant-owned model. It's the cheapest insurance you'll ever buy.

09Pitfalls and fixes

  1. Raw queries skip scopes

    DB::table() ignores global scopes; add where tenant_id yourself.

  2. Unique columns

    Make them unique per tenant: unique([tenant_id, email]).

  3. Cache key collisions

    Prefix cache keys with the tenant id.

  4. Route model binding

    Scoped models 404 on other tenants; test it.

10Checklist

  1. Tenant resolved from subdomain

    Unknown subdomains return 404.

  2. Every tenant table has tenant_id

    And uses BelongsToTenant.

  3. Billing on the tenant

    Webhook registered and tested.

  4. Isolation tests in CI

    They run on every push.