Building a B2B SaaS platform for transport companies, I faced a critical architectural decision: separate database per tenant or shared database with logical isolation?
I chose shared database. Here's why and how I implemented bulletproof data isolation with pure Laravel.
Why Shared Database
For a B2B SaaS, database evolution is critical:
Pros:
- Atomic migrations (add a column once, not 500 times)
- Simplified backup/restore
- Lower infrastructure costs
Cons:
- Logical isolation (not physical)
- Noisy neighbor risk
If I ever reach 500 tenants, managing 500 separate migrations per feature would be a full-time job. Shared DB wins.
The Architecture
USER REQUEST
│
▼
MIDDLEWARE (EnsureTenantAccess)
│ 1. Verify authentication
│ 2. Extract tenant ID
│ 3. Store in TenantContext (singleton)
▼
ELOQUENT MODELS (with BelongsToTenant trait)
│ 4. Apply GlobalScope automatically
▼
DATABASE QUERY
SELECT * FROM table WHERE tenant_id = [X]
TenantContext: The Source of Truth
class TenantContext
{
private ?string $tenantId = null;
public function set(string $id): void
{
$this->tenantId = $id;
}
public function id(): string
{
return $this->tenantId;
}
public function isSet(): bool
{
return $this->tenantId !== null;
}
}
Singleton. One instance per request. Zero ambiguity.
BelongsToTenant Trait
This is where the magic happens:
trait BelongsToTenant
{
public static function bootBelongsToTenant(): void
{
// READ: Auto-filter all queries
static::addGlobalScope('tenant', function (Builder $query) {
$context = app(TenantContext::class);
if ($context->isSet()) {
$query->where('tenant_id', $context->id());
}
});
// WRITE: Auto-assign tenant_id on create
static::creating(function (Model $model) {
$context = app(TenantContext::class);
if ($context->isSet() && !$model->tenant_id) {
$model->tenant_id = $context->id();
}
});
}
}
Every tenant-specific model (Customers, Vehicles, Transports) uses this trait. No manual tenant_id assignment. No forgetfulness bugs.
Testing Cross-Tenant Isolation
public function test_cross_tenant_isolation(): void
{
$tenantA = Tenant::factory()->create();
$tenantB = Tenant::factory()->create();
Product::factory()->for($tenantA)->create(['name' => 'Item A']);
Product::factory()->for($tenantB)->create(['name' => 'Item B']);
$response = $this->actingAs($this->userInTenant($tenantA))
->getJson('/api/v1/products');
$response->assertOk();
$response->assertJsonCount(1, 'data');
$response->assertJsonPath('data.0.name', 'Item A');
}
Without this test, I'm just hoping the GlobalScope works. Hope is not a strategy.
Super Admin Impersonation
Support needs to see what customers see. Solution:
$tenantId = $user->isSuperAdmin()
? session('impersonate_tenant_id')
: $user->tenant_id;
Super Admin has no fixed tenant_id. Via dashboard, they select a tenant to impersonate. Session stores the ID. Middleware populates TenantContext. Same exact views as the customer.
Read-Only Mode
if ($tenant->is_read_only && $request->isMethodSafe() === false) {
abort(403, 'Account in read-only mode');
}
Use cases:
- Payment suspension
- Maintenance windows
- Investigation locks
All centralized in middleware. Zero controller pollution.
Anti-Patterns Learned
- Manual tenant_id assignment: You WILL forget. Use the trait.
- Unique indexes without tenant_id: Always composite (tenant_id + unique_field)
- Incremental IDs for tenants: Use UUIDs. Prevents ID guessing attacks.
Would I Use a Package?
Spatie has spatie/laravel-multitenancy. It's solid.
But building my own gave me:
- Full control over edge cases
- Super admin impersonation (not trivial with packages)
- Deep understanding of the isolation boundaries
If you're learning or need custom flows, build it yourself. If you need speed and standard patterns, use a package.
Conclusion
Multi-tenancy with Laravel is about trust: trust in your automation, distrust in human memory.
Global Scopes + TenantContext + comprehensive tests = sleep well at night.
Top comments (0)