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

At a glance

Reading time

~200 words/min

Published

3 weeks ago

May 31, 2026

Views

140

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.

How did this land?

Comments

0
Log in or sign up to join the discussion and react to this post.

No comments yet. Be the first to share your thoughts.

Related posts

Essential Sorting Algorithms for Computer Science Students

Algorithms are commonly taught in Computer Science, Software Engineering subjects at your Bachelors or Masters. Some find it difficult to understand due to memorizing.

6 years ago

GraphQL in Laravel Using Lighthouse

In modern web development, GraphQL has emerged as a powerful alternative to REST APIs due to its flexibility and efficiency.

1 year ago

Building Modern Reactive UIs with Laravel 12 and Livewire 4: A Production Guide

A production-grade walkthrough of Livewire 4 in Laravel 12 — form objects, lazy components, Alpine interop, file uploads, Pest tests, and the deployment gotchas nobody warns you about.

1 week ago

Building Powerful Admin Panels with Laravel 12 and Filament v5: A Production Guide

Ship a real Filament v5 admin panel on Laravel 12 — Resources, RBAC with Spatie, multi-tenancy, custom widgets, and a deployment checklist for teams beyond hello-world.

1 month ago

Scaling Laravel 12 with Octane and FrankenPHP: A Production Performance Guide

Cut Laravel 12 latency by more than half with Octane and FrankenPHP — install, configure, audit singletons, and benchmark, with the production gotchas that bite teams in week two.

1 month ago