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.
Comments
0No comments yet. Be the first to share your thoughts.