Skip to content

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:

  1. Database strategy -- Single-Database or Multi-Database
  2. Sub-structure within tenant -- None, Teams, Workspaces, or Workspaces + 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:

php
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 domains table)

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:

php
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:

php
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:

php
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:

php
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));
    }
}
php
class RemoveTenantMemberAction
{
    public function execute(Tenant $tenant, User $user): void
    {
        $tenant->users()->detach($user->id);
        event(new TenantMemberRemoved($tenant, $user));
    }
}

Events

EventDispatched By
TenantCreatedCreateTenantAction
TenantMemberAddedAddTenantMemberAction
TenantMemberRemovedRemoveTenantMemberAction

All events are located in app/Events/Tenants/.

Database Schema

tenants table:

php
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:

php
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:

php
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']);
});