Multi-tenancy

Multi-tenancy

One application, several customers (shops, schools, companies) whose data never mixes. Shared database: every row carries its tenant's ID, filtered automatically.

Installation

bash
./bin/niang tenancy:install   # migration for the tenants table (name, slug, domain)
./bin/niang migrate
.env
TENANCY_ENABLED=true

Disabled by default: without TENANCY_ENABLED, nothing changes. config/tenancy.php sets the identification mode, the table name and the column name (tenant_id). niang doctor reports a missing tenants table.

Tenant models

php
// migration
Schema::create('projects', function ($table) {
    $table->id();
    $table->foreignId('tenant_id')->constrained('tenants');
    $table->string('name');
    $table->timestamps();
});

// app/Models/Project.php
class Project extends Model
{
    protected static array $fillable = ['name'];
    protected static bool $tenantScoped = true;
}

Project::all();                          // only the current tenant's projects
Project::create(['name' => 'Website']);  // tenant_id filled in automatically
Project::find($idOfAnotherTenant);       // null

The filter applies to everything that goes through the model: query(), find(), paginate(), update(), destroy(), relations and with(), pivots included. The tenant_id column is imposed on creation (a provided value is replaced) and update() cannot change it: a row never moves to another tenant.

Safe by default. A tenant model queried without a current tenant throws TenancyException instead of returning every customer's rows: a route that forgets the middleware fails, it does not leak. Hand-written SQL (DB::select(), new QueryBuilder('projects')) is not filtered.

Identifying the tenant

The App\Middleware\IdentifyTenant middleware finds the request's tenant, or answers 404.

routes/web.php
use App\Middleware\IdentifyTenant;

// Subdomain: acme.example.com, globex.example.com
$router->domain('{tenant}.example.com', function ($router) {
    $router->group(['middleware' => [IdentifyTenant::class]], function ($router) {
        $router->get('/projects', [ProjectController::class, 'index']);
    });
});

// Or URL prefix: example.com/acme/projects
$router->group(['prefix' => '/{tenant}', 'middleware' => [IdentifyTenant::class]], function ($router) {
    $router->get('/projects', [ProjectController::class, 'index']);
});
TENANCY_IDENTIFY_BYThe tenant comes from
route (default)route parameter {tenant} (subdomain or prefix), slug column
domainfull request host, domain column (customers' own domains)
headerX-Tenant header, slug column (APIs)

Identifying the tenant does not say the logged-in user belongs to it. Make the users table itself per tenant, or check membership (for example in a Policy) — essential with header, which the client chooses freely.

The current tenant

php
use Niang\Core\Tenancy;

Tenancy::current();   // the tenants table row, or null
Tenancy::id();

// Commands, seeders, scheduled tasks: act for one tenant
Tenancy::run($tenant, fn () => Project::create(['name' => 'Demo']));   // row or ID

// Platform administration: every tenant
$total = Tenancy::central(fn () => Project::query()->count());

Cache, jobs, logs

  • Cache: each tenant has its own keys; Cache::remember('stats', ...) never returns another tenant's statistics. Outside a tenant, keys are shared.
  • Jobs: a job queued during a tenant's request is run by the worker for that same tenant.
  • Logs: every message carries tenant, besides the request_id.
  • Metrics: shared by the whole platform, never split by tenant.

A complete example (organizations, roles, invitations, subscriptions): the “saas” starter.

What is not supported

A database or schema per tenant: sessions, cache, tokens and the queue live in the same database as the data, and would have to stay on a central connection. This choice is explained in the framework's ADR 0011 (docs/adr/).

⏱ 7.68 ms 🗄 0 requête(s) SQL 🧠 4.00 MB ↩ 200