[Field-Ready Complete Guide] Designing a Multi-Tenant SaaS in Laravel — Tenant Isolation (DB/Schema/Row), Domain/URL Strategy, Billing & Authorization, Auditing, Performance, and an Accessible Admin UI
What You’ll Learn (Key takeaways)
- Multi-tenant approaches (separate databases / separate schemas / row-level isolation) and how to choose + migration strategy
- Tenant resolution (subdomain / custom domain / URL prefix) and middleware implementation
- Protecting tenant boundaries “by structure”: global scopes, policies, storage, cache, queues
- Billing fundamentals (plans / seats / usage), authorization (roles/RBAC), invites, org management basics
- Audit logs, data retention, deletion/cancellation, backup/restore, incident response
- Performance optimization (indexes, materialized aggregates, job separation)
- Accessibility for admin screens (keyboard operation, tables/forms, non-color state, screen readers, error flows)
Intended Readers (Who benefits?)
- Laravel beginner–intermediate engineers: turning a single-tenant app into a SaaS that safely handles multiple organizations
- Tech leads / CTOs: balancing separation strength vs. operational cost and setting a standard architecture
- PM / CS / Legal / Security: designing with operational reality (billing, permissions, auditing, deletion) in mind
- Designers / QA / Accessibility: shaping an org admin UI that “everyone can use”
Accessibility Level: ★★★★★
This guide concretizes design, copy, and implementation for org switching, invites, permission settings, billing input errors, table sorting, notifications, live updates, and state indicators that don’t rely on color alone.
1. Introduction: The Scariest Risk in Multi-Tenancy Is “Boundary Leakage”
A multi-tenant SaaS uses one application to serve multiple organizations (tenants). The biggest risk is a user from Tenant A seeing Tenant B’s data. This can happen due to bugs or operational mistakes. That’s why your boundary must be protected not by “human caution,” but by structure.
This guide collects a practical, field-ready Laravel pattern—from choosing an approach to middleware, data isolation, billing and roles, auditing, performance, and accessibility—end to end.
2. Multi-Tenant Isolation Models: Separate DB / Separate Schema / Row Isolation
2.1 Quick comparison
-
Separate DB (one database per tenant)
- Strong: strongest boundary, easy backup/deletion, strong for legal requirements
- Weak: DB count grows → heavier operations (connections, migrations, monitoring)
- Best for: enterprise, strict isolation requirements, small–mid tenant counts
-
Separate schema (schemas inside one DB)
- Strong: lighter than separate DB, still a solid boundary
- Weak: DB-vendor dependent; operational complexity remains
- Best for: environments that handle schemas well (e.g., PostgreSQL)
-
Row isolation (single tables with
tenant_id)- Strong: lightest ops, scales well (one app set)
- Weak: mistakes directly cause leakage; indexing is crucial
- Best for: startups / SMB SaaS, large tenant counts
2.2 Practical conclusion (common in real projects)
- Start with row isolation and protect boundaries structurally (scope/middleware/tests).
- If a strict enterprise customer appears later, add separate DB for them as a “two-story” architecture—often realistic.
3. Tenant Resolution: Determining Which Org the Request Belongs To
3.1 Common patterns
- Subdomain:
acme.example.com - Custom domain:
app.acme.co.jp(requires DNS setup) - URL prefix:
example.com/t/acme(simplest; explicit for sharing/SEO)
Subdomains are the most common SaaS default. Custom domains are often an enterprise requirement—so it’s wise to design so you can add them later.
3.2 Resolving tenant in middleware
// app/Http/Middleware/ResolveTenant.php
namespace App\Http\Middleware;
use Closure;
use App\Models\Tenant;
class ResolveTenant
{
public function handle($request, Closure $next)
{
// Example: parse from subdomain
$host = $request->getHost(); // acme.example.com
$slug = explode('.', $host)[0]; // acme
$tenant = Tenant::where('slug', $slug)->first();
abort_if(!$tenant, 404);
app()->instance('tenant', $tenant);
return $next($request);
}
}
// helper
function tenant(): \App\Models\Tenant {
return app('tenant');
}
3.3 Apply to routes
Route::middleware(['resolve.tenant', 'auth'])->group(function () {
Route::get('/dashboard', DashboardController::class)->name('dashboard');
});
4. Enforcing Boundaries: Use Global Scopes to Prevent Leakage “Structurally”
With row isolation, every table includes tenant_id, and every query must include that condition. Doing it manually is dangerous, so force it with Laravel’s global scopes.
// app/Models/Concerns/BelongsToTenant.php
namespace App\Models\Concerns;
use Illuminate\Database\Eloquent\Builder;
trait BelongsToTenant
{
protected static function bootBelongsToTenant()
{
static::addGlobalScope('tenant', function (Builder $builder) {
if (app()->bound('tenant')) {
$builder->where($builder->getModel()->getTable().'.tenant_id', tenant()->id);
}
});
static::creating(function ($model) {
if (app()->bound('tenant') && empty($model->tenant_id)) {
$model->tenant_id = tenant()->id;
}
});
}
}
class Project extends Model {
use \App\Models\Concerns\BelongsToTenant;
protected $fillable = ['name','tenant_id'];
}
Key points
- Auto-set
tenant_idincreatingto prevent “forgotten tenant_id” writes. - For operator/admin screens that span tenants, use
withoutGlobalScope('tenant')carefully. - Even safer: put cross-tenant operations in a separate app or separate connection.
5. Authorization: Double-Guard with Tenant Boundary + RBAC
5.1 Example role design
- Owner (contract/billing/deletion included)
- Admin (settings/member management)
- Member (normal operations)
- Viewer (read-only)
5.2 Policy example (tenant match + role)
public function update(User $user, Project $project): bool
{
if ($project->tenant_id !== $user->tenant_id) return false;
return $user->hasRole('admin') || $user->hasRole('owner');
}
- “Scope” + “authorization” as two layers is strong.
- For invite-link onboarding, make the tenant explicit to prevent mistaken joins and mix-ups.
6. Billing: A Realistic Split of Plans, Seats, and Usage
6.1 The basics
- Plan (monthly/annual): feature limits (projects, storage)
- Seats: number of users
- Usage: API calls / storage / message volume
Don’t do everything at once. A realistic start is:
- Plan + seats (or plan only)
6.2 Common pitfalls in usage metering
- Avoid double-counting due to retries/duplicates (idempotency keys)
- Don’t aggregate in real time—materialize via scheduled jobs
- Keep auditable logs (e.g.,
usage_events) for traceability
7. Storage, Cache, Sessions: Separate by Tenant
7.1 Storage path namespacing
- Use
tenants/{tenant_id}/...to prevent accidental cross-reads - Prefer signed URLs; avoid public direct placement
$path = "tenants/".tenant()->id."/uploads/".$filename;
Storage::disk('s3')->put($path, $content);
7.2 Cache key namespacing
$key = "t:".tenant()->id.":projects:all";
Cache::remember($key, 300, fn()=> Project::orderBy('id')->get());
- Cache leaks easily—always include tenant ID in keys.
7.3 Tenant propagation in jobs (queues)
Pass tenant_id into the job and restore tenant context during processing.
class RecalcUsage implements ShouldQueue
{
public function __construct(public int $tenantId) {}
public function handle()
{
app()->instance('tenant', Tenant::findOrFail($this->tenantId));
// tenant() now works
}
}
8. Deletion and Retention: Design Cancellation Into the System
- Soft delete (recoverable window) → hard delete (permanent)
- Backups must also respect deletion policy (retention limits)
- Confirm legal/contractual requirements first (e.g., invoice retention)
8.1 Example cancellation flow
- Owner-only
- Two-step confirmation (retype org name)
- Explain scope (what is deleted vs. billing data retained)
- Post-completion support path
Accessibility note: don’t rely on color alone for warnings—use headings and bullet lists.
9. Audit Logging: Record “Who Did What” Per Tenant
9.1 Core audit fields
tenant_idactor_user_idaction(e.g.,member.invited,role.changed)target_type/target_idbefore/after(minimum needed)ip/user_agent/trace_idcreated_at
AuditLog::create([
'tenant_id' => tenant()->id,
'actor_user_id' => auth()->id(),
'action' => 'member.invited',
'target_type' => 'user',
'target_id' => $invitee->id,
'meta' => ['email_masked' => mask_email($invitee->email)],
'trace_id' => request()->header('X-Trace-Id'),
]);
10. Performance: Eliminate Row-Isolation Bottlenecks
10.1 Indexing rules of thumb
- For most tables, consider
(tenant_id, id)or(tenant_id, created_at) - For frequent filters:
(tenant_id, status, created_at)etc. - Unique constraints should be tenant-scoped:
unique(tenant_id, slug)
10.2 Materialize aggregates
Dashboard aggregates get heavy if computed on-demand. Prefer:
- scheduled aggregates into
daily_usage,tenant_stats, etc. - the UI reads those tables only
This stays stable as tenant count grows.
10.3 Archiving
Old audit/event logs can be moved per requirements into:
- separate tables
- separate DB
- object storage
to protect hot-table performance.
11. Admin UX: Prevent “Accidents” in Org Switching, Invites, and Permissions
11.1 Tenant switcher (org switch UI)
- Always show the current org name
- After switching, move focus to the page title
- Show org name + description, not only IDs
<nav aria-label="Switch organization">
<p>Current organization: {{ tenant()->name }}</p>
<ul>
@foreach($memberships as $m)
<li>
<a href="{{ $m->tenant->url }}" aria-current="{{ $m->tenant_id===tenant()->id ? 'true':'false' }}">
{{ $m->tenant->name }}
</a>
</li>
@endforeach
</ul>
</nav>
11.2 Accessible invitation flow
- Summarize errors with
role="alert", connect fields viaaria-describedby - Send invitation emails with a plain-text alternative; use specific link text
- For expired invites, avoid generic 419/403—provide a dedicated explanation page
12. Testing Tenant Boundaries: “Stop Leaks in CI”
With row isolation, the nightmare is “one screen forgets the tenant filter.” The best defense is tests that reproduce the leak and fail.
12.1 Feature test (cross-tenant data must not appear)
public function test_tenant_isolation()
{
$t1 = Tenant::factory()->create();
$t2 = Tenant::factory()->create();
$u1 = User::factory()->create(['tenant_id'=>$t1->id]);
$p2 = Project::factory()->create(['tenant_id'=>$t2->id]);
$this->actingAs($u1);
app()->instance('tenant', $t1);
$res = $this->get('/projects');
$res->assertOk();
$res->assertDontSee($p2->name);
}
12.2 Audit log tests
- Ensure important actions (role change, invite, billing changes) always create audit logs.
13. Incident Response: A Minimum Runbook for Boundary Leaks
- Identify scope (which tenants / time window / feature)
- Search logs using
trace_id,tenant_id,user_id - Temporary containment (feature flag off, permission tightening, maintenance page)
- Fix + prevent recurrence (add tests, enforce scopes, adjust review steps)
- User communication (what happened, impact, prevention, support contact)
For SaaS, “explain and fix quickly” tends to build more trust than “hide it.”
14. Common Pitfalls and How to Avoid Them
- Forgetting
tenant_idon one query- Fix: global scope + auto-assign on create; cross-tenant ops in separate app
- Cache keys missing tenant
- Fix: enforce
t:{tenant_id}in naming conventions
- Fix: enforce
- Jobs running without tenant context
- Fix: pass
tenant_idand restore inhandle()
- Fix: pass
- Unique constraints not tenant-scoped
- Fix:
unique(tenant_id, ...)
- Fix:
- Billing mismatch between UI and reality
- Fix: materialized aggregates + audit logs; idempotent change events
- Confusing org switch UI
- Fix: always show current org; focus heading after switch
- No cancellation/deletion design
- Fix: decide retention/deletion/backup policies first
15. Checklist (Shareable)
Isolation / boundaries
- [ ] Choose isolation model (row/schema/DB) + migration strategy
- [ ] Tenant resolution middleware (subdomain/custom domain/URL)
- [ ] Enforce
tenant_idvia global scopes - [ ] Double-guard with Policy (tenant match + roles)
Data & surrounding systems
- [ ] Namespaced storage paths + signed URLs
- [ ] Cache keys include tenant ID
- [ ]
tenant_idpropagation in jobs - [ ] Unique constraints and indexes include
tenant_id
Operations
- [ ] Audit logs for critical actions
- [ ] Cancellation/deletion/retention/backup policy
- [ ] Incident runbook
Billing / permissions
- [ ] Define plan/seats/usage scope
- [ ] Invite flow + expiry/invalidation
- [ ] RBAC (Owner/Admin/Member/Viewer)
Accessibility
- [ ] Org switching clarity +
aria-current - [ ] Form errors with
role="alert"+aria-describedby - [ ] State indicators not reliant on color alone
- [ ] Keyboard-friendly tables/lists + heading structure
Testing
- [ ] Tests that prevent cross-tenant reads
- [ ] Tests that ensure audit logs are created
- [ ] Tests for cache/job tenant separation
16. Summary
In a multi-tenant SaaS, preventing tenant-boundary leakage is the core. In Laravel, you can protect boundaries “by structure” using tenant-resolution middleware and global scopes, then build on top with RBAC, billing, audit logs, deletion/retention, and performance optimization. And the more you design org switching, invites, and permission screens with accessibility in mind, the fewer operational accidents you’ll have. Design calmly, seal leaks with tests, and grow a SaaS people can trust over the long run.
Reference Links
-
Laravel official docs
-
Common multi-tenancy packages
-
Security / operations
-
Accessibility
