Building Multi-Tenant SaaS with Laravel

· 5 min read
laravel php saas multi-tenant

Prerequisites

Before we crack on, make sure you’ve got:

  • Laravel 10+ installed and working
  • A reasonable understanding of Eloquent relationships
  • Database migrations knowledge (you know, up() goes up, down() goes down)
  • At least one mass-produced energy drink within arm’s reach
  • A mass-produced energy drink for your mass-produced energy drink
  • The quiet confidence of someone who has never accidentally dropped a production database

What We’re Building

A multi-tenant Laravel application where each customer gets isolated data. Single codebase, single database, many tenants. No messing about with separate databases per customer, because aint nobody got time for that.

If you’ve ever built something and then thought “right, now I need twelve copies of this for twelve different clients,” this is the post for you. We’re going to make Laravel do the boring bit automatically so you can focus on the interesting problems. Like why your CSS still doesnt work.

The Approach

  1. Add tenant identification to models
  2. Create global scopes for automatic filtering
  3. Build middleware for tenant resolution
  4. Handle tenant context throughout the request

Four steps. Sounds manageable. Famous last words.

Morpheus asking what if I told you

Step 1: Create the Tenant Model

php artisan make:model Tenant -m

This gives us our migration to work with:

// database/migrations/create_tenants_table.php
Schema::create('tenants', function (Blueprint $table) {
    $table->id();
    $table->string('name');
    $table->string('slug')->unique();
    $table->string('domain')->nullable()->unique();
    $table->timestamps();
});

Nothing exotic here. A name, a slug for URL routing, an optional domain for the fancy clients who want their own subdomain. Standard stuff.

Now add tenant_id to your existing tables:

Schema::table('projects', function (Blueprint $table) {
    $table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
});

That cascadeOnDelete is doing important work. When a tenant goes, their data goes with them. Clean breaks.

Step 2: Create a Trait for Tenant Models

This is where the magic lives. A trait that handles both scoping queries and automatically setting the tenant on new records:

// app/Concerns/BelongsToTenant.php
namespace App\Concerns;

use App\Models\Tenant;
use App\Models\Scopes\TenantScope;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

trait BelongsToTenant
{
    protected static function bootBelongsToTenant(): void
    {
        static::addGlobalScope(new TenantScope);

        static::creating(function ($model) {
            if (app()->bound('current_tenant')) {
                $model->tenant_id = app('current_tenant')->id;
            }
        });
    }

    public function tenant(): BelongsTo
    {
        return $this->belongsTo(Tenant::class);
    }
}

The bootBelongsToTenant method fires when the trait is loaded. It registers a global scope (more on that in a second) and hooks into the creating event to stamp every new record with the current tenant’s ID. Your controllers dont need to know or care about tenancy. They just carry on as normal.

Step 3: Create the Global Scope

The global scope is the real workhorse. Every query on a tenant model gets a silent WHERE tenant_id = ? appended:

// app/Models/Scopes/TenantScope.php
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('tenant_id', app('current_tenant')->id);
        }
    }
}

Twelve lines of code. That’s it. Twelve lines and your entire application is now tenant-aware. Every SELECT, every relationship, every eager load. All filtered. Automatically.

It’s alive

Step 4: Tenant Resolution Middleware

We need something to figure out which tenant is making the request. Middleware to the rescue:

// app/Http/Middleware/ResolveTenant.php
namespace App\Http\Middleware;

use App\Models\Tenant;
use Closure;
use Illuminate\Http\Request;

class ResolveTenant
{
    public function handle(Request $request, Closure $next)
    {
        $tenant = $this->resolveTenant($request);

        if (!$tenant) {
            abort(404, 'Tenant not found');
        }

        app()->instance('current_tenant', $tenant);

        return $next($request);
    }

    private function resolveTenant(Request $request): ?Tenant
    {
        if ($request->route('tenant')) {
            return Tenant::where('slug', $request->route('tenant'))->first();
        }

        $host = $request->getHost();
        return Tenant::where('domain', $host)->first();
    }
}

Two resolution strategies here. First it checks the route for a tenant slug (like /acme/projects). If that’s not there, it falls back to domain matching. You could extend this with header-based resolution, JWT claims, whatever your authentication setup demands.

Register it in bootstrap/app.php:

->withMiddleware(function (Middleware $middleware) {
    $middleware->alias([
        'tenant' => \App\Http\Middleware\ResolveTenant::class,
    ]);
})

Now just slap tenant middleware on any route group that needs it.

Step 5: Apply to Models

Here’s the satisfying bit. Making a model tenant-aware is one line:

// app/Models/Project.php
namespace App\Models;

use App\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;

class Project extends Model
{
    use BelongsToTenant;
}

That’s the whole change. Now all queries are automatically scoped:

// Only returns projects for current tenant
Project::all();

// Creates with tenant_id automatically set
Project::create(['name' => 'New Project']);

Your controller code stays blissfully ignorant of multi-tenancy. It just works. Project::all() returns exactly what it should for the current tenant and nothing more. No accidental data leaks, no cross-tenant contamination. Beautiful.

Fry shut up and take my money

Step 6: Handle Queue Jobs

Here’s where people get caught out. Jobs run outside the request lifecycle, so there’s no middleware to resolve the tenant. You need to pass it in explicitly:

// app/Jobs/ProcessReport.php
class ProcessReport implements ShouldQueue
{
    public function __construct(
        public Tenant $tenant,
        public Report $report
    ) {}

    public function handle(): void
    {
        app()->instance('current_tenant', $this->tenant);

        // Now scopes work correctly
        $data = Project::where('report_id', $this->report->id)->get();
    }
}

The tenant gets serialised with the job payload, and when the worker picks it up, the first thing it does is bind the tenant back into the container. Scopes kick in, everything works. Skip this step and your queue workers will cheerfully process data from every tenant in the system. Ask me how I know.

Surprised Pikachu

Step 7: Testing with Tenants

Testing multi-tenant code isnt complicated, but you do need to be deliberate about setting the context:

// tests/Feature/ProjectTest.php
public function test_projects_are_scoped_to_tenant(): void
{
    $tenant1 = Tenant::factory()->create();
    $tenant2 = Tenant::factory()->create();

    app()->instance('current_tenant', $tenant1);
    $project1 = Project::factory()->create();

    app()->instance('current_tenant', $tenant2);
    $project2 = Project::factory()->create();

    $this->assertCount(1, Project::all());
    $this->assertTrue(Project::all()->contains($project2));
    $this->assertFalse(Project::all()->contains($project1));
}

Create data under one tenant, switch context, verify the other tenant cant see it. If this test passes, your isolation is working. If it fails, well, you’ve got bigger problems than this blog post can solve.

The Result

  • Automatic data isolation per tenant
  • No code changes needed in controllers
  • Works with relationships and eager loading
  • Scales to thousands of tenants

What I’d Do Differently

Add tenant context to logging from day one. When debugging production issues, “which tenant was this?” becomes critical information you wish you’d captured. Seriously, future you will send a thank you card. Stick the tenant ID on every log entry, every exception report, every queue job failure. It costs nothing upfront and saves hours of detective work later.

Multi-tenancy in Laravel is simpler than it looks. Global scopes do the heavy lifting, you just need to set up the plumbing. Now go build something worth charging for.

Related Posts

Comments