How to Add Multitenancy to Laravel App: A Complete Guide

We earn commissions when you shop through the links below.

If you’re building a SaaS product, understanding how to add multitenancy to Laravel app is one of the most important architectural decisions you’ll make. Get it right early and scaling to hundreds of tenants is smooth. Bolt it on later and you’re in for a painful refactor. In this guide I’ll walk through the two main approaches — single database and multi-database — implement them with real code, and show you how the Tenancy for Laravel package makes the whole process manageable.

Single Database vs Multi-Database Multitenancy

Before writing a single line of code, you need to decide how tenants are isolated at the data layer.

  • Single database, shared tables: Every table has a tenant_id column. Simple to set up, but you must never forget to scope a query.
  • Single database, separate schemas: Supported in PostgreSQL. Good balance between isolation and resource usage.
  • Separate database per tenant: Maximum isolation. Easier GDPR compliance. More infrastructure overhead.

For most early-stage SaaS apps I recommend starting with the single-database approach using a tenant_id column, then migrating to per-database isolation once you have paying customers who demand it. Let’s implement both paths.

Approach 1: Single Database with tenant_id

This is the quickest way to add multitenancy to a Laravel app without any external package.

1. Create the tenants table

php artisan make:migration create_tenants_table
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('tenants', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->string('domain')->unique();
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('tenants');
    }
};

2. Add tenant_id to all tenant-scoped tables

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

3. Resolve the current tenant from the request

Create a middleware that reads the subdomain and binds the tenant to the container:

<?php

namespace App\Http\Middleware;

use App\Models\Tenant;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

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

        $tenant = Tenant::where('domain', $subdomain)->firstOrFail();

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

        return $next($request);
    }
}

4. Create a base model that automatically scopes queries

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;

class TenantScopedModel extends Model
{
    protected static function booted(): void
    {
        static::addGlobalScope('tenant', function (Builder $builder) {
            if (app()->has('tenant')) {
                $builder->where('tenant_id', app('tenant')->id);
            }
        });

        static::creating(function (Model $model) {
            if (app()->has('tenant') && empty($model->tenant_id)) {
                $model->tenant_id = app('tenant')->id;
            }
        });
    }
}

Now extend this instead of Model for anything that belongs to a tenant:

class Project extends TenantScopedModel
{
    protected $fillable = ['name', 'description'];
}

Every Project::all(), Project::find(), and Project::create() call is now automatically scoped. You’ll never accidentally leak data between tenants as long as the middleware runs.

Approach 2: Per-Tenant Databases with Tenancy for Laravel

When you need full database isolation, the Tenancy for Laravel package (by Stancl) is the best option in the ecosystem. It handles database creation, migrations, and connection switching automatically.

Install the package

composer require stancl/tenancy
php artisan tenancy:install
php artisan migrate

Configure tenancy.php

// config/tenancy.php
'tenant_model' => \App\Models\Tenant::class,

'id_generator' => Stancl\Tenancy\UUIDGenerator::class,

'domain_model' => Stancl\Tenancy\Database\Models\Domain::class,

Update your Tenant model

<?php

namespace App\Models;

use Stancl\Tenancy\Database\Models\Tenant as BaseTenant;
use Stancl\Tenancy\Contracts\TenantWithDatabase;
use Stancl\Tenancy\Database\Concerns\HasDatabase;
use Stancl\Tenancy\Database\Concerns\HasDomains;

class Tenant extends BaseTenant implements TenantWithDatabase
{
    use HasDatabase, HasDomains;
}

Create a tenant and its database programmatically

$tenant = Tenant::create(['id' => 'acme']);
$tenant->domains()->create(['domain' => 'acme.yourapp.com']);

// This creates the database and runs migrations automatically
// if you have the JobPipeline set up in TenancyServiceProvider

Place tenant routes in the correct route file

The package creates routes/tenant.php. Routes in this file are automatically wrapped with the InitializeTenancyByDomain middleware. Any Eloquent call inside these routes queries the tenant’s own database — no tenant_id column needed.

// routes/tenant.php
use Illuminate\Support\Facades\Route;

Route::middleware(['web'])->group(function () {
    Route::get('/dashboard', [DashboardController::class, 'index']);
    Route::resource('projects', ProjectController::class);
});

Tenant Onboarding Flow

Regardless of approach, you need a registration flow that creates the tenant record and provisions the workspace. Here’s a minimal controller:

<?php

namespace App\Http\Controllers;

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

class TenantRegistrationController extends Controller
{
    public function store(Request $request)
    {
        $request->validate([
            'company_name' => 'required|string|max:255',
            'subdomain'    => 'required|alpha_dash|unique:tenants,domain',
            'email'        => 'required|email',
            'password'     => 'required|min:8|confirmed',
        ]);

        $tenant = Tenant::create([
            'name'   => $request->company_name,
            'domain' => $request->subdomain,
        ]);

        // For per-database approach the package handles DB creation via events
        // For single-DB approach, create the first admin user here
        tenancy()->initialize($tenant);

        $user = \App\Models\User::create([
            'name'     => $request->company_name,
            'email'    => $request->email,
            'password' => bcrypt($request->password),
        ]);

        auth()->login($user);

        return redirect('http://' . $request->subdomain . '.yourapp.com/dashboard');
    }
}

Testing Multitenancy Locally

Local subdomain testing is the first hurdle most developers hit. Add entries to /etc/hosts:

127.0.0.1 acme.yourapp.test
127.0.0.1 beta.yourapp.test

Then in your Valet or Herd config, make sure wildcard domains are supported. For Docker-based setups, Traefik labels work well for routing *.yourapp.test to the same container.

When it comes to deploying, I typically reach for DigitalOcean managed databases alongside a droplet or App Platform instance. The managed database service handles replication and backups, which is critical when each tenant has their own database and you’re managing dozens of them.

Queue and Cache Isolation

Don’t forget queues and cache. A job dispatched by tenant A should not accidentally read tenant B’s cache. With Tenancy for Laravel, bootstrap the tenant inside the job:

<?php

namespace App\Jobs;

use Stancl\Tenancy\Concerns\TenantAwareJob;

class ProcessInvoice implements ShouldQueue
{
    use TenantAwareJob;

    public function handle(): void
    {
        // Current tenant database is active here
        Invoice::where('status', 'pending')->update(['status' => 'processing']);
    }
}

For cache, prefix cache keys with the tenant ID:

Cache::put('tenant_' . tenant('id') . '_settings', $settings, 3600);

Common Pitfalls

  • Forgetting to scope raw queries: DB::statement() bypasses Eloquent global scopes. Always pass tenant context explicitly.
  • Central vs tenant routes confusion: Your marketing pages, billing webhooks (Stripe), and admin panel live on the central domain. Keep them in routes/web.php, not routes/tenant.php.
  • Seeding tenant databases: Use php artisan tenants:seed with the Tenancy package rather than the standard db:seed command.
  • File storage: Prefix all storage paths with the tenant slug so files don’t bleed across tenants.

Learning More

If you want a structured deep dive into SaaS architecture patterns beyond just multitenancy, Udemy has several solid Laravel SaaS courses that cover billing, feature flags, and multi-tenant deployment pipelines in one package.

Wrapping Up

Knowing how to add multitenancy to Laravel app properly from the start saves you weeks of painful migrations later. Use the single-database approach for simplicity and speed, and migrate to per-database isolation when compliance or customer demand requires it. The Tenancy for Laravel package handles the heavy lifting for the per-database path — the global scopes approach is all you need for the simpler route.

The most important thing: pick your strategy before you write your first model, and enforce the tenant boundary at the middleware layer so no query ever runs without proper context.