Building a Multi-Tenant Laravel App with Single-Database Scoping
There are two common ways to build a multi-tenant SaaS: give each tenant its own database, or keep everyone in one database and scope every row to a tenant. The single-database approach is simpler to operate and works well until you have very large tenants. In this tutorial we will build it from scratch for a fictional invoicing app run by PHP Architect.
The core idea is straightforward. Every tenant-owned table gets a tenant_id column, we resolve the current tenant on each request, and a global scope silently filters every query. Done right, your feature code never has to think about tenancy at all.
Step 1: The Tenant Model and Migrations
First, a tenants table and a tenant_id on any table that belongs to a tenant.
Schema::create('tenants', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('subdomain')->unique();
$table->timestamps();
});
Schema::create('invoices', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->string('number');
$table->integer('amount_cents');
$table->timestamps();
});
Step 2: Resolve the Current Tenant
We need a single source of truth for "who is the current tenant." A simple container binding works well and lets us inject it anywhere.
namespace App\Tenancy;
use App\Models\Tenant;
class TenantManager
{
protected ?Tenant $tenant = null;
public function set(Tenant $tenant): void
{
$this->tenant = $tenant;
}
public function current(): ?Tenant
{
return $this->tenant;
}
public function id(): ?int
{
return $this->tenant?->id;
}
}
Register it as a singleton in a service provider so the same instance is shared for the whole request:
$this->app->singleton(TenantManager::class);
Step 3: Middleware to Identify the Tenant
For this example we resolve the tenant from the subdomain. Middleware runs early, so the tenant is set before any controller or query executes.
namespace App\Http\Middleware;
use App\Models\Tenant;
use App\Tenancy\TenantManager;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class IdentifyTenant
{
public function __construct(protected TenantManager $tenants) {}
public function handle(Request $request, Closure $next): Response
{
$subdomain = explode('.', $request->getHost())[0];
$tenant = Tenant::where('subdomain', $subdomain)->firstOrFail();
$this->tenants->set($tenant);
return $next($request);
}
}
Register the middleware in bootstrap/app.php and apply it to your tenant-facing routes:
->withMiddleware(function (Middleware $middleware) {
$middleware->alias([
'tenant' => \App\Http\Middleware\IdentifyTenant::class,
]);
})
Step 4: The Global Scope That Ties It Together
This is where the magic happens. A global scope reads the current tenant and adds the where clause to every query automatically.
namespace App\Tenancy;
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 ($tenantId = app(TenantManager::class)->id()) {
$builder->where($model->getTable() . '.tenant_id', $tenantId);
}
}
}
To avoid repeating yourself, put the scope and an auto-fill hook in a trait, then use it on every tenant-owned model:
namespace App\Tenancy;
trait BelongsToTenant
{
public static function bootBelongsToTenant(): void
{
static::addGlobalScope(new TenantScope);
static::creating(function ($model) {
$model->tenant_id ??= app(TenantManager::class)->id();
});
}
}
Now the model is tiny, and it never mentions tenancy in its business logic:
namespace App\Models;
use App\Tenancy\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
class Invoice extends Model
{
use BelongsToTenant;
protected $fillable = ['number', 'amount_cents'];
}
Step 5: See It Work
With the middleware applied, a controller stays completely tenant-agnostic:
public function index()
{
// Only returns invoices for the current tenant, automatically.
return Invoice::latest()->get();
}
public function store(Request $request)
{
// tenant_id is filled in for you on creation.
return Invoice::create($request->only('number', 'amount_cents'));
}
Things to Watch For
The creating hook fills tenant_id on new records, but a global scope only filters reads. It does not protect against a malicious tenant_id submitted in a form, so never mass-assign that column from user input. Keep it out of $fillable, as we did above.
Also remember that queries outside an HTTP request, such as queued jobs and Artisan commands, have no tenant set by the middleware. In those contexts you must set the tenant explicitly with app(TenantManager::class)->set($tenant) before touching tenant data, or the scope will return every tenant's rows.
For production apps with complex needs, the community package stancl/tenancy and spatie/laravel-multitenancy cover edge cases like cached tenant resolution and per-tenant queues. But understanding the pattern by hand, as we did here, makes those tools far easier to reason about.
Further Reading
- Laravel documentation, Eloquent: "Global Scopes" (laravel.com/docs/13.x/eloquent#global-scopes)
- Laravel documentation, "Middleware" (laravel.com/docs/13.x/middleware)
- Tenancy for Laravel documentation (tenancyforlaravel.com)