DEV Community

Software Solutions
Software Solutions

Posted on • Edited on

Architectural Blueprint: Building a Multi-Tenant SaaS Application with Laravel

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] │
└───────────────────────┘   └───────────────────────┘
Enter fullscreen mode Exit fullscreen mode
  1. 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.
  1. 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);
    }
}

Enter fullscreen mode Exit fullscreen mode

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);
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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

  1. 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.

  2. Tenant-Scoped Caching: Never use simple keys like cache()->get('settings'). Always prefix cache keys: cache()->get("tenant_{$tenant->id}_settings").

  3. 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 (1)

Collapse
 
mihirkanzariya profile image
Mihir kanzariya

Solid writeup. Two things I would add, and they are the same failure wearing different hats: the global scope protects Eloquent, and the leaks happen where Eloquent is not.

DB::table(), raw selects, and whatever a reporting or migration script does all bypass BelongsToTenant entirely. On Postgres, row level security gives you the guarantee at the database instead of the ORM, so a raw query cannot cross tenants even when someone forgets. Keep the global scope for ergonomics, but RLS is what turns it from a convention into a boundary.

Your queue point is the right catch and has the same shape. Anything running outside the request needs the tenant bound explicitly: scheduled commands, webhook handlers reacting to an external event, console sessions. All of them are easy to miss because they work perfectly in development, where you only have one tenant.

On the test, I would assert on the trait rather than only on the access. Checking that tenant B cannot read tenant A is necessary, but the regression that actually bites is the model somebody adds six months from now without BelongsToTenant. A test that enumerates tenant-scoped tables and fails when one lacks the trait catches the leak you have not written yet.