Building a standard web application with Laravel is straightforward. However, transitioning from single-tenant logic to a Multi-Tenant SaaS architecture introduces fundamental challenges: data isolation, dynamic database routing, background queue scoping, and scalable authorization.
Whether you are building an HR portal, a School Management SaaS, or an enterprise CRM, deciding on your tenancy pattern early will prevent costly technical debt as your platform grows.
Here is a practical, code-heavy walkthrough on how to architect a multi-tenant application in Laravel.
1. Choosing the Right Tenancy Architecture
Before writing any code, you need to decide how isolated your tenants' data needs to be.
Pattern 1: Shared Database (Single Schema + tenant_id)
┌──────────────────────────────────────────────────────────┐
│ Central Database │
│ [ Users (tenant_id) ] [ Invoices (tenant_id) ] │
└──────────────────────────────────────────────────────────┘
Pattern 2: Multi-Database / Schema-per-Tenant
┌───────────────────────┐ ┌───────────────────────┐
│ Tenant A Database │ │ Tenant B Database │
│ [ Users ] [Invoices] │ │ [ Users ] [Invoices] │
└───────────────────────┘ └───────────────────────┘
- Shared Database (tenant_id Column): All tenants share the same database tables. Every query is scoped using a tenant_id foreign key.
- Pros: Extremely cheap infrastructure, fast migrations, easy cross-tenant analytics.
- Cons: Risk of data leaks if a query scope is missed.
- Multi-Database (Dedicated Database per Tenant): Each tenant gets their own isolated database connection.
- Pros: Enterprise-grade isolation, easy backups per tenant, regulatory compliance (GDPR/HIPAA).
- Cons: Complex migration pipelines and higher infrastructure costs.
2. Implementing Tenant Scoping in Single Database Architectures
If you opt for the single database pattern, relying on developers to manually write ->where('tenant_id', $tenantId) on every query is a recipe for disaster.
Instead, leverage Laravel Global Scopes and Model Traits.
Step 1: Create the Scoping Trait
namespace App\Models\Traits;
use App\Models\Scopes\TenantScope;
use App\Models\Tenant;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
trait BelongsToTenant
{
protected static function bootBelongsToTenant(): void
{
// Apply global scope for fetching records
static::addGlobalScope(new TenantScope());
// Automatically assign tenant_id on model creation
static::creating(function ($model) {
if (app()->bound('current_tenant') && !$model->tenant_id) {
$model->tenant_id = app('current_tenant')->id;
}
});
}
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
}
Step 2: Write the Global Scope
namespace App\Models\Scopes;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;
class TenantScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
if (app()->bound('current_tenant')) {
$builder->where($model->getTable() . '.tenant_id', app('current_tenant')->id);
}
}
}
Now, any model using use BelongsToTenant; will automatically isolate queries to the active context without extra boilerplate code!
3. Resolving the Active Tenant via Middleware
Your application needs to determine who the incoming request belongs to before executing controller logic. The most common approach is resolving via subdomains or custom domains.
namespace App\Http\Middleware;
use Closure;
use App\Models\Tenant;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class IdentifyTenant
{
public function handle(Request $request, Closure $next): Response
{
$host = $request->getHost(); // e.g., client1.saasapp.com
$subdomain = explode('.', $host)[0];
// Fetch tenant from cache or DB
$tenant = cache()->remember("tenant_{$subdomain}", 3600, function () use ($subdomain) {
return Tenant::where('slug', $subdomain)->where('is_active', true)->first();
});
if (!$tenant) {
abort(404, 'Tenant account not found or suspended.');
}
// Bind active tenant into Laravel's Service Container
app()->instance('current_tenant', $tenant);
return $next($request);
}
}
4. Handling Background Jobs & Queue Context Loss
A common pitfall in multi-tenant Laravel systems occurs when dispatching Queued Jobs.
When a job is sent to Redis or SQS, HTTP middleware doesn't run during queue processing. If your background worker processes a job without knowing the active tenant, it risks writing data to the wrong client or failing globally!
To fix this, pass the active tenant ID into the job payload and bind it on execution:
namespace App\Jobs;
use App\Models\Invoice;
use App\Models\Tenant;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class ProcessTenantInvoice implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
public Tenant $tenant,
public Invoice $invoice
) {}
public function handle(): void
{
// Explicitly set the active context inside the queue worker thread
app()->instance('current_tenant', $this->tenant);
// Perform tenant-scoped operations
$this->invoice->generatePdfAndEmail();
}
}
5. Ecosystem Packages to Accelerate Development
While building a custom tenancy handler teaches deep framework mechanics, production systems often rely on battle-tested ecosystem packages:
- stancl/tenancy: The gold standard for multi-database and automatic routing setups. It automatically switches DB connections, caches, file storage disks, and Redis keys without modifying model code.
- spatie/laravel-multitenancy: A lightweight, highly flexible package maintained by Spatie that integrates smoothly with single and multi-database patterns.
Summary & Best Practices
Enforce Indexing: Always create composite indexes on (tenant_id, created_at) or (tenant_id, id) to keep queries performant as tables reach millions of rows.
Tenant-Scoped Caching: Never use simple keys like cache()->get('settings'). Always prefix cache keys: cache()->get("tenant_{$tenant->id}_settings").
Automate Testing: Write automated tests using PHPUnit/Pest to explicitly verify that Tenant B cannot access Tenant A's primary keys via direct URL manipulation.
Need Help Scaling Your SaaS Platform?
Architecting multi-tenant database platforms, high-performance APIs, and secure web applications requires experienced software engineering.
👉 Partner with Software Solutions for enterprise-grade web development, custom CRM/ERP builds, AI integrations, and cloud automation tailored to scale your software products seamlessly.
Top comments (0)