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
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);
}
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>.
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.
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
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.