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
./bin/niang tenancy:install # migration for the tenants table (name, slug, domain)
./bin/niang migrate
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
// 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.
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_BY | The tenant comes from |
|---|---|
route (default) | route parameter {tenant} (subdomain or prefix), slug column |
domain | full request host, domain column (customers' own domains) |
header | X-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
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 therequest_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/).