Tenant Overview
Introduction
When running nubos:init and selecting Tenant as the organization type, your application is scaffolded for full multi-tenancy. Tenants are identified by subdomain and support two database strategies: single-database (shared) and multi-database (dedicated per tenant).
The nubos:init command asks two follow-up questions for tenant organizations:
- Database strategy --
Single-DatabaseorMulti-Database - Sub-structure within tenant --
None,Teams,Workspaces, orWorkspaces + Teams
Tenant Creation
The CreateTenantAction creates a tenant with a subdomain, validates the subdomain format, checks for reserved names, and creates a primary domain record:
class CreateTenantAction
{
private const RESERVED_SUBDOMAINS = [
'www', 'api', 'admin', 'app', 'mail', 'ftp', 'staging', 'preview',
];
public function execute(User $owner, array $data): Tenant
{
return DB::transaction(function () use ($owner, $data): Tenant {
$subdomain = $data['slug'] ?? Str::slug($data['name']);
$this->validateSubdomain($subdomain);
$tenant = Tenant::query()->create([
'name' => $data['name'],
'slug' => $subdomain,
'owner_id' => $owner->id,
]);
Domain::query()->create([
'tenant_id' => $tenant->id,
'domain' => $subdomain,
'is_primary' => true,
]);
$tenant->users()->attach($owner->id, ['role' => 'owner']);
event(new TenantCreated($tenant));
return $tenant;
});
}
}Subdomain validation enforces:
- Format: lowercase alphanumeric with hyphens, 3-63 characters (
/^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$/) - Not in the reserved list
- Not already taken (checked against the
domainstable)
Subdomain Routing
Tenants are identified by subdomain through the TenantIdentification middleware. It extracts the first segment of the hostname, looks it up in the domains table (falling back to the tenants.slug column), verifies the authenticated user's membership, and binds the tenant to the container:
class TenantIdentification
{
public function handle(Request $request, Closure $next): Response
{
$host = $request->getHost();
$subdomain = explode('.', $host)[0] ?? null;
if (!$subdomain) {
abort(404);
}
$domain = Domain::query()
->where('domain', $subdomain)
->first();
if (!$domain) {
$tenant = Tenant::query()
->where('slug', $subdomain)
->first();
} else {
$tenant = $domain->tenant;
}
if (!$tenant) {
abort(404);
}
if ($request->user() && !$request->user()->belongsToTenant($tenant)) {
abort(403);
}
app()->instance('current_tenant', $tenant);
$request->attributes->set('current_tenant', $tenant);
if (config('nubos.database_strategy') === 'multi') {
$tenant->configureDatabaseConnection();
}
return $next($request);
}
}All tenant routes use this middleware:
Route::middleware(['web', 'auth', TenantIdentification::class])
->group(function (): void {
Route::get('/dashboard', function () {
return inertia('Dashboard');
})->name('dashboard');
});Unlike Team/Workspace routes, tenant routes do not use a URL prefix. The tenant context comes from the subdomain: acme.yourapp.com/dashboard.
User Trait
The BelongsToTenant trait is added to the User model and provides:
trait BelongsToTenant
{
public function tenants(): BelongsToMany
{
return $this->belongsToMany(Tenant::class)
->withPivot('role')
->withTimestamps();
}
public function currentTenant(): ?Tenant
{
return app()->bound('current_tenant') ? app('current_tenant') : null;
}
public function belongsToTenant(Tenant $tenant): bool
{
return $this->tenants()->where('tenants.id', $tenant->id)->exists();
}
}Member Management
The AddTenantMemberAction and RemoveTenantMemberAction follow the same pattern as Team/Workspace actions:
class AddTenantMemberAction
{
public function execute(Tenant $tenant, User $user, ?string $role = null): void
{
$tenant->users()->attach($user->id, ['role' => $role]);
event(new TenantMemberAdded($tenant, $user));
}
}class RemoveTenantMemberAction
{
public function execute(Tenant $tenant, User $user): void
{
$tenant->users()->detach($user->id);
event(new TenantMemberRemoved($tenant, $user));
}
}Events
| Event | Dispatched By |
|---|---|
TenantCreated | CreateTenantAction |
TenantMemberAdded | AddTenantMemberAction |
TenantMemberRemoved | RemoveTenantMemberAction |
All events are located in app/Events/Tenants/.
Database Schema
tenants table:
Schema::create('tenants', function (Blueprint $table): void {
$table->uuid('id')->primary();
$table->string('name');
$table->string('slug')->unique();
$table->foreignUuid('owner_id')->constrained('users')->cascadeOnDelete();
$table->timestamps();
$table->softDeletes();
});domains table:
Schema::create('domains', function (Blueprint $table): void {
$table->uuid('id')->primary();
$table->foreignUuid('tenant_id')->constrained()->cascadeOnDelete();
$table->string('domain')->unique();
$table->boolean('is_primary')->default(false);
$table->timestamps();
$table->softDeletes();
});tenant_user pivot:
Schema::create('tenant_user', function (Blueprint $table): void {
$table->uuid('id')->primary();
$table->foreignUuid('tenant_id')->constrained()->cascadeOnDelete();
$table->foreignUuid('user_id')->constrained()->cascadeOnDelete();
$table->string('role')->nullable();
$table->timestamps();
$table->softDeletes();
$table->unique(['tenant_id', 'user_id']);
});