For years, the SaaS landscape has been awash in the promise of effortless multi-tenancy. Everyone expects a magic wand, a single toggle to magically separate customer data. The prevailing wisdom? Grab the latest, shiniest package and be on your merry way. But what if that’s not the only path? What if you could build strong, secure isolation right into the core of your Laravel application, without adding another dependency to your already bloated dependency tree?
That’s precisely the challenge one developer tackled building a B2B transport company SaaS. The fundamental question loomed large: a separate database for each client, or a single, shared database with clever logical segregation? Predictably, they opted for the latter, a choice that often raises eyebrows but, in this case, looks downright sensible.
The Shared Database Gambit
The argument for a shared database in B2B SaaS is compelling, especially when you start thinking about scale. Forget managing hundreds — potentially thousands — of identical database migrations for every minor schema tweak. Atomic migrations, simpler backups, and a lighter infrastructure footprint are huge wins. But the elephant in the room, the big scary con, is the risk of data leakage. The infamous “noisy neighbor” problem looms large; one tenant’s heavy query shouldn’t bog down everyone else.
If I ever reach 500 tenants, managing 500 separate migrations per feature would be a full-time job. Shared DB wins.
This pragmatic view is crucial. While separate databases offer the ultimate physical isolation, the operational overhead for developers and infra teams at scale can become a nightmare. The author’s decision to embrace shared infrastructure for efficiency, while still demanding ironclad data isolation, is where things get interesting.
The Architecture: A Symphony of Scopes and Context
At its heart, this approach hinges on a few core Laravel components working in concert. It’s a clean, dependency-free implementation that feels like a breath of fresh air in a world obsessed with externalizing everything. The flow is elegant: middleware intercepts requests, identifies the tenant, injects that ID into a central context, and then Eloquent’s Global Scopes do the heavy lifting, ensuring every query is automatically filtered.
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]
The TenantContext class is simple, a singleton that holds the current tenant ID for the duration of a request. No magic, just straightforward state management.
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;
}
}
This is where the magic truly happens, the BelongsToTenant trait. It’s attached to every model that needs tenant-specific data. It intercepts query building and creation events, ensuring that tenant_id is always present and always applied.
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();
}
});
}
}
This trait automates the filtering of read operations and the assignment of the tenant_id during write operations. It removes the burden of manual checks from controllers and models, drastically reducing the surface area for bugs related to forgetfulness. Developers can focus on feature logic, not on remembering to append a where tenant_id = ... clause.
Testing the Fortress
Code is only as good as its tests, and this implementation is no exception. The inclusion of a cross-tenant isolation test is a clear indicator of a mature development process. It’s not enough to hope the global scope is working; you need to prove it.
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');
}
This unit test, at a glance, seems simple. But it’s the bedrock of trust in this system. It verifies that a user authenticated within tenantA can only retrieve products belonging to tenantA, and crucially, not tenantB. This level of rigor is precisely what separates a strong system from one riddled with potential breaches.
Super Admin Impersonation: A Practical Edge Case
One of the often-overlooked complexities in multi-tenant systems is the role of a super administrator. How do they see what their clients see? How do they debug issues without needing complex, role-based access control that rivals the complexity of the core application?
The solution here is elegantly simple: impersonation. A super admin can select a tenant to “impersonate,” and that selection is stored, allowing the middleware to inject the correct tenant_id into the TenantContext. This means the entire application – views, API endpoints, background jobs – behaves as if the super admin is that tenant, offering unparalleled debugging and support capabilities.
$tenantId = $user->isSuperAdmin()
? session('impersonate_tenant_id')
: $user->tenant_id;
This bit of code is powerful. It illustrates that for support and internal tooling, abstracting away the tenant context is sometimes necessary, and doing it cleanly without breaking the core isolation logic is a sign of good design.
Read-Only Modes: Beyond Payment Suspensions
The inclusion of a read-only mode, triggered centrally in middleware, is another layer of sophisticated control. It’s not just about pausing subscriptions; it’s about managing operational states – maintenance, investigations, or even temporary account lockdowns.
if ($tenant->is_read_only && $request->isMethodSafe() === false) {
abort(403, 'Account in read-only mode');
}
This prevents any destructive operations (POST, PUT, DELETE) from executing on a tenant flagged as read-only. Keeping this logic out of individual controllers keeps the controllers lean and focused on their primary responsibilities.
Key Tenets of Trustworthy Multi-Tenancy
The author distills some critical advice: manual tenant_id assignment is a recipe for disaster. Forget it, and you’ve opened the door. Unique indexes must always include the tenant_id. And for IDs, lean towards UUIDs to prevent easy enumeration and guessing attacks.
To Package or Not to Package?
Spatie’s spatie/laravel-multitenancy package is mentioned, and rightly so, as a strong solution. It’s a proof to the community’s efforts. But the article’s core thesis is that building your own can offer distinct advantages:
- Unparalleled Control: You dictate how edge cases are handled.
- Custom Flows: Super admin impersonation is a prime example.
- Deep Understanding: You gain intimate knowledge of your system’s security boundaries.
For those needing rapid development with standard patterns, a package is the obvious choice. But for learning, for complex custom requirements, or when minimizing dependencies is a strategic goal, building it yourself might just be the smarter play.
Ultimately, multi-tenancy in Laravel, when done right, is about fostering trust – trust in your automated systems and a healthy distrust in human fallibility. Global Scopes, a clear tenant context, and rigorous testing are the pillars of this trust. They allow developers to sleep at night, secure in the knowledge that their clients’ data is precisely where it should be, and only where it should be.
When Should I Consider Building My Own Multi-Tenancy Solution?
If your project has highly specific, non-standard multi-tenancy requirements, like complex super-admin impersonation scenarios, or if you need granular control over every aspect of data isolation and want to avoid the learning curve and potential overhead of an external package, building your own solution can be a valid and rewarding path.
How Does This Approach Differ from Using a Package Like Spatie’s?
This approach prioritizes building custom logic using Laravel’s core features (Global Scopes, middleware, singletons) for maximum control and minimal external dependencies. Packages like Spatie’s offer pre-built, feature-rich solutions that are often faster to implement for common scenarios but may offer less flexibility for unique edge cases or require deeper understanding to customize effectively.