Building a Multi-Tenant SaaS with Laravel

A SaaS (Software as a Service) application serves many customers (tenants) from a single shared codebase. The core challenge is simple but critical: making sure Tenant A's data nev...

Building a Multi-Tenant SaaS with Laravel

A SaaS (Software as a Service) application serves many customers (tenants) from a single shared codebase. The core challenge is simple but critical: making sure Tenant A's data never leaks to Tenant B. This article covers two multi-tenancy approaches in Laravelsingle database (one database, a tenant_id column) and multi database (one database per tenant) — complete with code for a global scope, middleware, and tenant identification.

1. Choosing a Strategy: Single-DB vs Multi-DB

Before writing code, understand the trade-offs:

  • Single database (tenant_id column): all tenants share the same tables; every row is tagged with a tenant_id. Cheapest, easiest to deploy, and you migrate once. The risk: one query that forgets to filter tenant_id can leak data across tenants.
  • Multi database (one DB per tenant): strong isolation and easy per-customer backup/restore. Ideal for sensitive data. The cost: migrations must run against many databases, and the connection must be switched dynamically at runtime.

For most early-stage SaaS products, the single-DB approach is enough and the most productive. We cover it in depth, then show how to switch connections for multi-DB.

There is also a third approach that is often overlooked: schema per tenant on PostgreSQL, where all tenants live in one physical database but each tenant gets a separate schema. This offers better isolation than single-DB without exploding the number of databases like full multi-DB does. However, for simplicity and cross-database compatibility, this article focuses on the two main approaches most commonly used in the Laravel ecosystem.

2. Setting Up the tenant_id Column

Every tenant-owned table needs a marker column. Add it via a migration:

Schema::create('projects', function (Blueprint $table) {
    $table->id();
    $table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
    $table->string('name');
    $table->timestamps();
    $table->index('tenant_id');
});

An index on tenant_id matters because nearly every query will filter on this column.

3. Identifying the Tenant via Subdomain + Middleware

Before it can filter data, the app must know which tenant is active on a given request. There are several ways to identify a tenant: via a subdomain (most common for SaaS), via a path prefix like /t/acme/dashboard, or via a custom domain that customers point at your application. The subdomain is preferred because it is clean, easy to brand, and doesn't change your app's route structure. The most common pattern gives each tenant its own subdomain, e.g. acme.gudangcode.test. Create middleware to resolve the tenant from the subdomain and store it so it is reachable throughout the request:

class IdentifyTenant
{
    public function handle(Request $request, Closure $next)
    {
        $host = $request->getHost();
        $subdomain = explode('.', $host)[0];

        $tenant = Tenant::where('slug', $subdomain)->first();

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

        // Store the active tenant globally for this request
        app()->instance('currentTenant', $tenant);
        config(['app.tenant_id' => $tenant->id]);

        return $next($request);
    }
}

Register the middleware on a route group, or in bootstrap/app.php on Laravel 11:

->withMiddleware(function (Middleware $middleware) {
    $middleware->web(append: [
        \App\Http\Middleware\IdentifyTenant::class,
    ]);
})

4. Global Scope: Filtering Every Query Automatically

The heart of single-DB safety is ensuring every query automatically appends where tenant_id = .... Instead of relying on developers to remember, use a Global Scope:

class TenantScope implements Scope
{
    public function apply(Builder $builder, Model $model): void
    {
        if ($tenantId = config('app.tenant_id')) {
            $builder->where($model->getTable().'.tenant_id', $tenantId);
        }
    }
}

Create a trait so it is easy to attach to many models at once. The trait also fills tenant_id automatically when creating a new record:

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

        static::creating(function (Model $model) {
            if (! $model->tenant_id && $tenantId = config('app.tenant_id')) {
                $model->tenant_id = $tenantId;
            }
        });
    }
}

Use it on tenant models:

class Project extends Model
{
    use BelongsToTenant;

    protected $fillable = ['name'];
}

Now Project::all() automatically returns only the active tenant's projects, and Project::create(['name' => 'X']) fills tenant_id for you. Developers no longer write the filter manually.

5. The Multi-Database Approach

If you need full isolation, swap the database connection dynamically in the middleware once the tenant is resolved:

config([
    'database.connections.tenant.database' => 'tenant_'.$tenant->id,
]);

DB::purge('tenant');
DB::reconnect('tenant');

Then make tenant models use that connection with protected $connection = 'tenant';. Migrations run per tenant by looping over the tenant list and calling Artisan::call('migrate', ['--database' => 'tenant']) after the connection is pointed at the right database.

6. Common Pitfalls

  • Raw queries bypass scopes: DB::table('projects')->get() does not go through Eloquent, so the global scope does not apply. Always add the tenant filter manually on raw query builder calls.
  • Cross-tenant relations: whereHas and eager loading still need checking so related models also use the tenant trait.
  • Jobs and queues: when a job runs in the background, config('app.tenant_id') is absent. Store the tenant_id in the job payload and re-set it inside handle().
  • Cache and session leakage: prefix cache keys with the tenant_id so they never collide across tenants.
  • Bypassing the scope on purpose: for super-admin pages, use Model::withoutGlobalScope(TenantScope::class) deliberately.

7. Testing Tenant Isolation

Multi-tenant security should never be assumed; it must be tested. Write a test that creates two tenants, seeds data for each, then verifies that when tenant A is active, queries never return tenant B's data. A test like this becomes a safety net when you add new models — if a developer forgets to attach the tenant trait, the leakage test fails immediately. Ideally, build one base test that runs against every tenant model so coverage is comprehensive.

For larger scale, consider using a mature package like stancl/tenancy that already handles identification, connection switching, cache bootstrapping, and queue isolation automatically. Rolling your own is great for understanding the mechanics, but in production a battle-tested package saves a lot of time and closes edge cases that are easy to miss, such as file storage isolation and per-tenant broadcasting.

With a tenant_id column, an automatic global scope, and clean identification middleware, you have a safe multi-tenant SaaS foundation without duplicating code. Start with single-DB, and move to multi-DB only when isolation or regulatory needs demand it. Most importantly: treat tenant isolation as a core security feature, not a mere technical detail, because a single cross-tenant data leak can destroy the trust of every customer at once.

Yudhi
Written by
Yudhi
Founder & Lead Developer, GudangCode

Yudhi is the founder of GudangCode and a Laravel developer who has built dozens of ready-to-use business information systems — from POS and HRIS to management apps. He writes guides and articles on GudangCode to help Indonesian developers run, understand, and deploy Laravel source code correctly.

LaravelPHPMySQLSistem Informasi Bisnis See all articles by Yudhi
Want the full source code & apps?

Sign up free to download ready-to-use business applications, information systems, and Laravel source code.

Sign Up Free & Download
Laravel SaaS Multi-Tenant Development
Share this article
Back to Blog
📚 Free Learning Hub

Learn Coding for Free at DhieCoderWeb

Explore Laravel, PHP, JavaScript tutorials, source code, web development guides, and practical programming tips.

DhieCoderWeb
100+
Tutorials
Free
Learning
SEO
Tips
Visit Dhiecoderweb.com →

Get Full Access Now!

Join our membership and unlock exclusive access to all premium features. Fast, easy, and ready to use instantly.

Join Membership Now
Tim Support
Online
Isi data dulu untuk mulai chat:
Beri rating & testimoni sebelum menutup:
Live chat by gudangcode.com