Cover image for Multi-Tenant SaaS with Laravel 12: A Production Architecture Guide

At a glance

Reading time

~200 words/min

Published

13 hours ago

May 31, 2026

Views

13

All-time total

Multi-Tenant SaaS with Laravel 12: A Production Architecture Guide

Almost every B2B Laravel app eventually grows into a multi-tenant SaaS. The decisions you make in the first month single database vs database-per-tenant, subdomain vs path-based routing, how you scope queries are very expensive to undo at month twelve. This guide is an opinionated walkthrough of the architecture I would pick for a new SaaS in 2026.

Decisions this guide makes for you

  • Single database, tenant_id column on every tenant-scoped table
  • Subdomain routing (acme.app.com), with a fallback path mode for free trials
  • Stancl/Tenancy v4 for the tenancy primitive, Spatie Permissions for roles
  • Tenant-aware queues, mail, and storage
  • Stripe Cashier with per-tenant subscriptions
i

Info

Single DB or DB-per-tenant?

Single database scales to thousands of tenants if you index <code>tenant_id</code> aggressively. DB-per-tenant is heavier ops (migrations, backups, connections) and only pays off when tenants need data isolation for regulatory reasons. Default to single DB; revisit at >5000 paying tenants.

1. The schema decision

-- 0001_create_workspaces_table.php
CREATE TABLE workspaces (
    id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
    slug VARCHAR(60) UNIQUE NOT NULL,
    name VARCHAR(120) NOT NULL,
    plan VARCHAR(40) DEFAULT 'free',
    trial_ends_at TIMESTAMP NULL,
    created_at TIMESTAMP NULL,
    updated_at TIMESTAMP NULL
);

-- Every tenant-scoped table gets this:
ALTER TABLE projects ADD COLUMN workspace_id BIGINT UNSIGNED NOT NULL AFTER id;
ALTER TABLE projects ADD INDEX idx_workspace (workspace_id);
ALTER TABLE projects ADD CONSTRAINT fk_projects_workspace
    FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE;

Warning

Index discipline is everything

On a single-DB tenancy model, every query MUST start with <code>WHERE workspace_id = ?</code> and that column must be the leading column of every relevant index. Without it, one busy tenant's queries do full-table scans across every tenant's data.

2. The tenancy primitive

// app/Models/Workspace.php
class Workspace extends Model
{
    protected $fillable = ['slug', 'name', 'plan', 'trial_ends_at'];
    protected $casts = ['trial_ends_at' => 'datetime'];

    public function users()    { return $this->belongsToMany(User::class)->withPivot('role'); }
    public function projects() { return $this->hasMany(Project::class); }
    public function owner()    { return $this->users()->wherePivot('role', 'owner'); }

    public static function current(): ?self { return app('current.workspace'); }
}
// app/Http/Middleware/ResolveWorkspace.php
class ResolveWorkspace
{
    public function handle(Request $request, Closure $next)
    {
        $host = $request->getHost();
        $rootDomain = config('app.root_domain');

        $slug = Str::endsWith($host, '.' . $rootDomain)
            ? Str::before($host, '.' . $rootDomain)
            : $request->route('workspace');

        $workspace = Workspace::where('slug', $slug)->firstOrFail();

        abort_unless($workspace->users->contains($request->user()), 403);

        app()->instance('current.workspace', $workspace);
        URL::defaults(['workspace' => $workspace->slug]);

        return $next($request);
    }
}

3. The global scope you cannot forget

// app/Models/Concerns/BelongsToWorkspace.php
trait BelongsToWorkspace
{
    protected static function bootBelongsToWorkspace(): void
    {
        static::addGlobalScope('workspace', function (Builder $query) {
            if ($workspace = Workspace::current()) {
                $query->where($query->getModel()->getTable() . '.workspace_id', $workspace->id);
            }
        });

        static::creating(function ($model) {
            if (! $model->workspace_id && $workspace = Workspace::current()) {
                $model->workspace_id = $workspace->id;
            }
        });
    }

    public function workspace() { return $this->belongsTo(Workspace::class); }
}

Danger

The global scope is necessary but not sufficient

A developer can call <code>Project::withoutGlobalScope('workspace')</code> and leak data. You also need: a Pest test that creates two workspaces and asserts every Resource shows zero cross-leak; a CI check that grep-bans <code>withoutGlobalScope</code>; a code review checklist for any direct DB query.

4. Tenant-aware queues

Jobs run outside the request lifecycle. Workspace::current() returns null inside a queued job unless you teach it the tenant.

abstract class TenantAwareJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $workspaceId;

    public function __construct()
    {
        $this->workspaceId = Workspace::current()?->id
            ?? throw new RuntimeException('TenantAwareJob dispatched outside a workspace context');
    }

    public function handle(): void
    {
        $workspace = Workspace::findOrFail($this->workspaceId);
        app()->instance('current.workspace', $workspace);
        $this->run();
    }

    abstract protected function run(): void;
}

// Every job extends this — no exceptions.
class GenerateMonthlyReport extends TenantAwareJob
{
    protected function run(): void
    {
        $report = Report::create([...]);
        // Project::query() now scopes to the right workspace automatically.
    }
}

5. Stripe Cashier per workspace

// app/Models/Workspace.php
use Laravel\Cashier\Billable;

class Workspace extends Model
{
    use Billable;

    public function stripeName(): string { return $this->name; }
    public function stripeEmail(): string { return $this->owner->first()->email; }
}

// Subscribing
$workspace->newSubscription('default', 'price_pro_monthly')
          ->trialDays(14)
          ->create($paymentMethodId);

// Gating features
public function handle(Request $request, Closure $next, string $feature)
{
    abort_unless(
        Workspace::current()->subscription('default')?->valid()
        && Workspace::current()->subscription('default')->hasProduct($feature),
        402
    );
    return $next($request);
}
1

Webhook the seat count

Stripe metered billing reports seats per period. Run <code>cashier:webhook</code> on deploy and update <code>workspaces.seat_count</code> on <code>customer.subscription.updated</code>.

2

Lock the trial cliff

Add a middleware that returns 402 when <code>trial_ends_at</code> is past and there is no active subscription. The error page tells the owner to upgrade.

3

Email the owner before the trial ends

Three days before, one day before, day-of. This single sequence improves trial-to-paid conversion more than any product change.

6. The ops checklist nobody talks about

Pros

  • Backups: nightly mysqldump, plus row-level <code>workspace_id</code> export per tenant on demand
  • Per-tenant rate limits using the <code>RateLimiter</code> facade keyed by workspace id
  • Log enrichment: every log line carries <code>workspace_id</code> via a Monolog processor
  • A "switch to tenant" admin tool — staff can impersonate read-only into any workspace

Cons

  • A noisy neighbour can choke shared resources — Octane workers, queue throughput, DB connections
  • Schema changes affect every tenant simultaneously; treat migrations as production incidents
  • Cross-tenant analytics queries are easy to write and easy to leak
100×

better engineering ROI from one well-designed multi-tenant app vs one app per customer

When to bail to DB-per-tenant

Note

The signal is regulatory, not technical

Healthcare, finance, government — any contract with "data residency" or "physical isolation" clauses pushes you toward DB-per-tenant. Otherwise, a well-indexed shared schema scales further than you think. I have run single-DB tenancy past 8000 paying workspaces on a single managed Postgres instance.

Final word

Multi-tenancy is not a Laravel feature you install — it is an architecture you commit to. Get the schema, the global scope, the tenant-aware jobs, and the test discipline right early, and the next three years of feature work compound on a foundation that does not leak. Get any of those wrong, and the cleanup will dominate your roadmap.

Success

Build the leak test first

Before you write a single feature: a Pest test that creates two workspaces, seeds them with disjoint data, and asserts that hitting every API endpoint as workspace A returns zero of workspace B's rows. Run it on every PR. This single test prevents the entire category of bugs that kill SaaS companies.

Newsletter

Want more posts like this?

Get practical software notes and tutorials delivered when something new is published.

No spam. Unsubscribe anytime.

Share

Related posts

CRUD Operations In Laravel 8

This tutorial is created to illustrate the basic CRUD (Create , Read, Update, Delete) operation using SQL with Laravel 8. Laravel is one of the fastest-growing frameworks for PHP.

4 years ago

Installing PHP on Windows

PHP is known to be one of the most commonly used server-side programming language on the web. It provides an easy to master with a simple learning curve. It has close ties with the MySQL database.

5 years ago

Scheduling Tasks with Cron Job in Laravel 5.8

Cron Job is used to schedule tasks that will be executed every so often. Crontab is a file that contains a list of scripts, By editing the Crontab, You can run the scripts periodically.

7 years ago

Connecting Multiple Databases in Laravel 5.8

This tutorial is created to implement multiple database connections using mysql. Let’s see how to configure multiple database connections in Laravel 5.8.

6 years ago

Integrating Google ReCaptcha in Laravel 5.8

reCAPTCHA is a free service from Google. It’s a CAPTCHA-like system designed to recognize that the user is human and, at the same time, assist in the digitization of books. It helps to protects your w

6 years ago