Multi-Tenancy in Laravel: A Practical Guide for SaaS Architecture
If you've ever built an application that needs to serve multiple clients or organizations within a single codebase — welcome to the world of multi-tenancy.
Multi-tenancy is an architecture where a single application instance serves multiple tenants (clients/organizations), with data isolated from one another.
This article covers the concept, strategies, and practical implementation in Laravel.
Why Multi-Tenancy?
Imagine you're building a ticketing system. Without multi-tenancy, each client needs a separate deployment — separate server, separate database, separate maintenance. Expensive and not scalable.
With multi-tenancy:
- 1 codebase for all clients
- Cost-efficient — shared infrastructure
- Easier maintenance — update once, all tenants get it
- Scalable — onboard a new tenant in minutes, not days
3 Database Strategies for Multi-Tenancy
1. Shared Database, Shared Schema (Column-based)
All tenants use 1 database, 1 table. Differentiated by a tenant_id column.
// Every query must filter by tenant_id
$orders = Order::where('tenant_id', auth()->user()->tenant_id)->get();
Pros: Simple, cheap, easy to maintain.
Cons: Risk of data leakage if you forget to filter. Requires high discipline.
2. Shared Database, Separate Schema
One database, but each tenant has its own schema (PostgreSQL-friendly).
Pros: Better isolation than option 1.
Cons: More complex migrations. Not ideal for MySQL.
3. Separate Database per Tenant
Each tenant gets their own database. Full isolation.
// Switch database connection per tenant
Config::set('database.connections.tenant.database', $tenant->database_name);
DB::purge('tenant');
Pros: Perfect isolation. Per-tenant backup/restore is easy.
Cons: More expensive. Migrations must run across all databases.
Which Strategy to Choose?
| Criteria | Column-based | Separate DB |
|---|---|---|
| Cost | Low | High |
| Data isolation | Low | High |
| Scalability | High | Medium |
| Complexity | Low | High |
| Best for | SaaS startups, MVPs | Enterprise, regulated industries |
Rule of thumb: Start with column-based. Migrate to separate databases when you have compliance requirements or enterprise clients that need full isolation.
Implementation in Laravel
Approach 1: Global Scope (Column-based)
The simplest way — use a Global Scope to auto-filter by tenant.
// app/Models/Traits/BelongsToTenant.php
trait BelongsToTenant
{
protected static function bootBelongsToTenant()
{
static::addGlobalScope('tenant', function ($query) {
if (auth()->check()) {
$query->where('tenant_id', auth()->user()->tenant_id);
}
});
static::creating(function ($model) {
if (auth()->check()) {
$model->tenant_id = auth()->user()->tenant_id;
}
});
}
}
Use it in your model:
class Order extends Model
{
use BelongsToTenant;
}
// Now auto-filtered, no manual where needed
$orders = Order::all(); // already per-tenant
Approach 2: Middleware + Tenant Resolution
Resolve the tenant from a subdomain, header, or URL path.
// app/Http/Middleware/ResolveTenant.php
class ResolveTenant
{
public function handle($request, Closure $next)
{
$subdomain = explode('.', $request->getHost())[0];
$tenant = Tenant::where('subdomain', $subdomain)->firstOrFail();
app()->instance('current_tenant', $tenant);
return $next($request);
}
}
Approach 3: Package (stancl/tenancy)
For the separate database approach, the stancl/tenancy package handles a lot out of the box:
composer require stancl/tenancy
php artisan tenancy:install
This package handles:
- Automatic database creation per tenant
- Database switching via middleware
- Tenant-aware migrations
- Domain/subdomain routing
- Event-driven tenant lifecycle
Best Practices
1. Never Trust the Frontend for Tenant Filtering
// DON'T: tenant_id from the request
$orders = Order::where('tenant_id', $request->tenant_id)->get();
// DO: tenant_id from auth/session
$orders = Order::where('tenant_id', auth()->user()->tenant_id)->get();
2. Test Multi-Tenant Scenarios
Always test cross-tenant access:
public function test_user_cannot_access_other_tenant_data()
{
$tenantA = Tenant::factory()->create();
$tenantB = Tenant::factory()->create();
$orderB = Order::factory()->for($tenantB)->create();
$this->actingAs($tenantA->users->first())
->get("/orders/{$orderB->id}")
->assertForbidden();
}
3. Cache Must Be Tenant-Aware
// Prefix cache key with tenant_id
Cache::tags(["tenant_{$tenantId}"])->remember("orders", 3600, fn() =>
Order::all()
);
4. Queue Jobs Must Carry Tenant Context
class ProcessOrder implements ShouldQueue
{
public $tenantId;
public function __construct($order)
{
$this->tenantId = $order->tenant_id;
}
public function handle()
{
// Set tenant context before processing
app()->instance('current_tenant', Tenant::find($this->tenantId));
}
}
When NOT to Use Multi-Tenancy
- Small app with 1-5 clients → overkill
- Clients have vastly different requirements → better as separate deployments
- Strict regulations requiring physical separation → separate infrastructure
Conclusion
Multi-tenancy isn't just a technical choice — it's an architectural decision that impacts cost, security, and scalability of your product.
Start simple (column-based), scale up when needed (separate DB), and always prioritize data isolation at every layer.
Most importantly: test cross-tenant access from day one. Data leakage between tenants = game over for client trust.
Got questions about multi-tenancy implementation? Reach out via LinkedIn.