T
Tenanto
Documentation / Multi Tenancy

Multi Tenancy

Updated Jan 25, 2026

Multi-Tenancy Architecture

This document describes the multi-tenancy implementation in Tenanto.


Overview

Tenanto uses a single database with tenant_id column approach. This provides:

┌─────────────────────────────────────────────────┐
│                Single Database                  │
├─────────────────────────────────────────────────┤
│  tenants        │  projects       │  tasks      │
│  ├── id         │  ├── tenant_id  │  ├── tenant_id
│  ├── slug       │  ├── name       │  ├── title
│  └── domain     │  └── ...        │  └── ...
│                                                 │
│  All tenant-aware tables have tenant_id column  │
└─────────────────────────────────────────────────┘

How It Works

1. Tenant Identification

Tenants are identified by:

Configuration in config/tenancy.php:

return [
    'mode' => env('TENANCY_MODE', 'subdomain'), // subdomain, domain, or both
    'subdomain_suffix' => env('TENANCY_SUBDOMAIN_SUFFIX', '.tenanto.local'),
    'central_domains' => array_filter([
        env('TENANCY_CENTRAL_DOMAIN', 'tenanto.local'),
        'admin.tenanto.local',
    ]),
];

2. Tenant Resolution

The TenantResolver class handles identification:

// Located at: app/Domain/Tenancy/TenantResolver.php

$resolver = new TenantResolver();
$tenant = $resolver->resolve($request);

// Caching built-in for performance
// Cache key: tenant_slug_{slug} or tenant_domain_{domain}

3. Middleware Setup

The TenantMiddleware sets up tenant context:

// Resolution order:
// 1. Authenticated user's tenant_id (most reliable)
// 2. Subdomain/domain from URL

// The middleware:
// - Stores tenant in service container
// - Sets Spatie Permission team context
// - Validates user has access to tenant

4. Automatic Query Scoping

The BelongsToTenant trait provides:

use App\Domain\Tenancy\Traits\BelongsToTenant;

class Project extends Model
{
    use BelongsToTenant;
}

// Now all queries are automatically scoped:
Project::all();
// SQL: SELECT * FROM projects WHERE tenant_id = ?

Project::where('name', 'like', '%test%')->get();
// SQL: SELECT * FROM projects WHERE tenant_id = ? AND name LIKE '%test%'

Implementation Details

TenantManagerInterface (Recommended)

For proper dependency injection and testability, use the TenantManagerInterface:

use App\Domain\Tenancy\Contracts\TenantManagerInterface;

// Via dependency injection (recommended)
public function __construct(
    private readonly TenantManagerInterface $tenantManager,
) {}

public function someMethod(): void
{
    $tenant = $this->tenantManager->current();
    $tenantId = $this->tenantManager->id();

    // Set tenant context (usually done by middleware)
    $this->tenantManager->set($someTenant);

    // Clear tenant context
    $this->tenantManager->clear();
}

// Via service container (when DI not available)
$tenantManager = app(TenantManagerInterface::class);
$tenant = $tenantManager->current();

TenantScope (Global Scope)

Located at app/Domain/Tenancy/Scopes/TenantScope.php:

final class TenantScope implements Scope
{
    public function apply(Builder $builder, Model $model): void
    {
        $tenantManager = app(TenantManagerInterface::class);
        $tenant = $tenantManager->current();

        if ($tenant !== null) {
            // Uses fully qualified column name for JOIN safety
            $builder->where($model->getTable().'.tenant_id', $tenant->id);
        }
    }
}

Auto-Assignment on Create

The trait automatically assigns tenant_id:

static::creating(function (Model $model): void {
    if (! $model->getAttribute('tenant_id')) {
        $tenantManager = app(TenantManagerInterface::class);
        $tenant = $tenantManager->current();
        if ($tenant !== null) {
            $model->setAttribute('tenant_id', $tenant->id);
        }
    }
});

Bypassing Tenant Scope (Admin Only)

For system admin operations:

// Method 1: Using macro
Project::withoutTenantScope()->get();

// Method 2: Using withoutGlobalScopes
Project::withoutGlobalScopes()->get();

// WARNING: Never use in tenant-facing code!

Creating Tenant-Aware Models

Step 1: Add the Trait

<?php

namespace App\Domain\YourModule\Models;

use App\Domain\Tenancy\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;

class YourModel extends Model
{
    use BelongsToTenant;

    protected $fillable = [
        'name',
        // Don't add tenant_id to fillable - it's auto-assigned
    ];
}

Step 2: Create Migration

Schema::create('your_models', function (Blueprint $table) {
    $table->id();
    $table->uuid('uuid')->unique();
    $table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
    $table->string('name');
    $table->timestamps();

    // IMPORTANT: Add composite index for performance
    $table->index(['tenant_id', 'created_at']);
});

Step 3: Create Policy (Defense in Depth)

class YourModelPolicy
{
    use HandlesAuthorization;

    public function view(User $user, YourModel $model): bool
    {
        return $user->tenant_id === $model->tenant_id;
    }

    public function update(User $user, YourModel $model): bool
    {
        return $user->tenant_id === $model->tenant_id;
    }

    public function delete(User $user, YourModel $model): bool
    {
        return $user->tenant_id === $model->tenant_id;
    }
}

Queue Jobs with Tenant Context

TenantAware Trait

Use the TenantAware trait in queue jobs that need to operate within a tenant context:

use App\Domain\Tenancy\Traits\TenantAware;
use Illuminate\Contracts\Queue\ShouldQueue;

class ProcessTenantDataJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
    use TenantAware;

    public function __construct()
    {
        // IMPORTANT: You must call this manually - traits don't auto-initialize
        $this->initializeTenantAware();
    }

    public function handle(): void
    {
        // Tenant context is automatically restored by middleware
        // All queries are scoped to the captured tenant
        $projects = Project::all();
    }
}

Dispatching Tenant-Aware Jobs

// Dispatch with current tenant (captured automatically in constructor)
ProcessTenantDataJob::dispatch();

// Dispatch for specific tenant
dispatch((new ProcessTenantDataJob())->forTenant($specificTenant));

// Or two-step:
$job = new ProcessTenantDataJob();
$job->forTenant($specificTenant);
dispatch($job);

How It Works

  1. initializeTenantAware(): Captures current tenant when job is created
  2. Serialization: Tenant model is serialized with job payload
  3. TenantAwareMiddleware: Restores tenant context when job executes
  4. Cleanup: Previous context is restored after job completes

Jobs That Don't Need TenantAware


Tenant Model

Located at app/Domain/Tenancy/Models/Tenant.php:

Key Properties

Property Type Description
id int Internal ID
uuid string Public identifier
name string Display name
slug string URL subdomain (unique)
domain string Custom domain (nullable, unique)
settings array JSON settings storage
is_active bool Can access the application
is_demo bool Demo tenant flag
demo_expires_at Carbon Demo expiration time

Key Relationships

// Users belonging to this tenant
$tenant->users;

// Teams within this tenant
$tenant->teams;

// Subscription (Laravel Cashier)
$tenant->subscription('default');

// Owner user
$tenant->owner();

Billable Entity

Tenanto uses Tenant as the billable entity (not User):

use Laravel\Cashier\Billable;

class Tenant extends Model
{
    use Billable;
}

// Subscribe a tenant
$tenant->newSubscription('default', $priceId)->create($paymentMethod);

Database Schema

Entity Relationship Diagram

┌─────────────────────────────────────────────────────────────────────────────────────┐
│                           TENANTO DATABASE SCHEMA (ERD)                              │
└─────────────────────────────────────────────────────────────────────────────────────┘

┌──────────────────┐         ┌──────────────────┐         ┌──────────────────┐
│     TENANTS      │         │      USERS       │         │      TEAMS       │
├──────────────────┤         ├──────────────────┤         ├──────────────────┤
│ id (PK)          │◄───────┐│ id (PK)          │┌───────►│ id (PK)          │
│ uuid             │        ││ tenant_id (FK)───┘│        │ tenant_id (FK)───┼──┐
│ name             │        │├──────────────────┤│        ├──────────────────┤  │
│ slug (unique)    │        ││ name             ││        │ name             │  │
│ domain (unique)  │        ││ email            ││        │ description      │  │
│ settings (JSON)  │        ││ password         ││        │ settings (JSON)  │  │
│ is_active        │        ││ is_tenant_owner  ││        │ created_at       │  │
│ is_demo          │        ││ email_verified_at││        │ updated_at       │  │
│ demo_expires_at  │        ││ created_at       ││        └────────┬─────────┘  │
│ trial_ends_at    │        ││ updated_at       ││                 │            │
│ stripe_id        │        │└──────────────────┘│                 │            │
│ pm_type          │        │                    │                 │            │
│ pm_last_four     │        │         ┌─────────┴──────────────────┴─────┐      │
│ created_at       │        │         │          TEAM_USER (Pivot)       │      │
│ updated_at       │        │         ├──────────────────────────────────┤      │
│ deleted_at       │        │         │ team_id (FK)─────────────────────┼──────┤
└────────┬─────────┘        │         │ user_id (FK)─────────────────────┘      │
         │                  │         │ role (enum: owner/admin/member)         │
         │                  │         └──────────────────────────────────┘      │
         │                  │                                                    │
         ├──────────────────┴────────────────────────────────────────────────────┘
         │
         │ (tenant_id foreign key on all tenant-scoped tables)
         │
         ▼
┌──────────────────┐         ┌──────────────────┐         ┌──────────────────┐
│    PROJECTS      │         │      TASKS       │         │  SUBSCRIPTIONS   │
├──────────────────┤         ├──────────────────┤         ├──────────────────┤
│ id (PK)          │◄───────┐│ id (PK)          │         │ id (PK)          │
│ tenant_id (FK)───┼──┐     ││ tenant_id (FK)───┼──┐      │ tenant_id (FK)───┼──┐
│ uuid             │  │     ││ project_id (FK)──┘  │      │ name             │  │
│ name             │  │     ││ uuid               │      │ stripe_id        │  │
│ description      │  │     ││ title              │      │ stripe_status    │  │
│ color            │  │     ││ description        │      │ stripe_price     │  │
│ is_archived      │  │     ││ status (enum)      │      │ quantity         │  │
│ settings (JSON)  │  │     ││ priority (enum)    │      │ trial_ends_at    │  │
│ created_at       │  │     ││ due_date           │      │ ends_at          │  │
│ updated_at       │  │     ││ completed_at       │      │ created_at       │  │
└──────────────────┘  │     ││ assigned_to (FK)───┼──────►│ updated_at       │  │
                      │     ││ created_by (FK)────┼──────►└──────────────────┘  │
                      │     ││ sort_order         │                             │
                      │     ││ created_at         │                             │
                      │     ││ updated_at         │                             │
                      │     │└──────────────────┘                               │
                      │     │                                                    │
                      └─────┴────────────────────────────────────────────────────┘
                            All tables with tenant_id are tenant-scoped

┌──────────────────┐         ┌──────────────────┐         ┌──────────────────┐
│    LICENSES      │         │ONBOARDING_PROGRESS│        │  PERSONAL_ACCESS │
├──────────────────┤         ├──────────────────┤         │     _TOKENS      │
│ id (PK)          │         │ id (PK)          │         ├──────────────────┤
│ key (unique)     │         │ tenant_id (FK)───┼──┐      │ id (PK)          │
│ tenant_id (FK)───┼──┐      │ user_id (FK)     │  │      │ tokenable_type   │
│ tier             │  │      │ step             │  │      │ tokenable_id     │
│ status           │  │      │ data (JSON)      │  │      │ name             │
│ activation_count │  │      │ completed        │  │      │ token            │
│ max_activations  │  │      │ created_at       │  │      │ abilities        │
│ expires_at       │  │      │ updated_at       │  │      │ last_used_at     │
│ last_validated_at│  │      └──────────────────┘  │      │ expires_at       │
│ metadata (JSON)  │  │                            │      │ created_at       │
│ created_at       │  │                            │      │ updated_at       │
│ updated_at       │  │                            │      └──────────────────┘
│ deleted_at       │  │                            │
└──────────────────┘  └────────────────────────────┘

LEGEND:
  PK = Primary Key
  FK = Foreign Key
  ──► = Foreign Key Reference
  ◄── = Referenced By

Key Relationships

Parent Child Relationship Notes
Tenant Users hasMany tenant_id on users
Tenant Teams hasMany tenant_id on teams
Tenant Projects hasMany tenant_id on projects
Tenant Tasks hasMany tenant_id on tasks
Tenant Subscriptions hasMany Laravel Cashier
Tenant Licenses hasMany License keys
Team Users belongsToMany Pivot with role
Project Tasks hasMany project_id on tasks
User Tasks hasMany (assigned) assigned_to on tasks
User Tasks hasMany (created) created_by on tasks

tenants Table

CREATE TABLE tenants (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    uuid CHAR(36) UNIQUE NOT NULL,
    name VARCHAR(255) NOT NULL,
    slug VARCHAR(255) UNIQUE NOT NULL,
    domain VARCHAR(255) UNIQUE NULL,
    settings JSON DEFAULT '{}',
    is_active BOOLEAN DEFAULT TRUE,
    is_demo BOOLEAN DEFAULT FALSE,
    demo_expires_at TIMESTAMP NULL,
    demo_warning_sent_at TIMESTAMP NULL,
    onboarding_completed_at TIMESTAMP NULL,
    created_at TIMESTAMP,
    updated_at TIMESTAMP,
    deleted_at TIMESTAMP NULL,

    INDEX idx_tenants_slug (slug),
    INDEX idx_tenants_domain (domain),
    INDEX idx_tenants_demo (is_demo, demo_expires_at)
);

Recommended Indexes for Tenant Tables

All tenant-aware tables should have:

-- Primary tenant filter
INDEX idx_{table}_tenant (tenant_id);

-- Composite indexes for common queries
INDEX idx_{table}_tenant_created (tenant_id, created_at);
INDEX idx_{table}_tenant_{filter} (tenant_id, {common_filter_column});

Configuration

Environment Variables

# Tenant identification mode
TENANCY_MODE=subdomain              # subdomain, domain, or both

# Subdomain suffix for resolution
TENANCY_SUBDOMAIN_SUFFIX=.tenanto.local

# Central domains (not tenant-specific)
TENANCY_CENTRAL_DOMAIN=tenanto.local

# Cache settings
TENANCY_CACHE_ENABLED=true
TENANCY_CACHE_TTL=3600              # seconds

Full Configuration

config/tenancy.php:

return [
    'mode' => env('TENANCY_MODE', 'subdomain'),
    'subdomain_suffix' => env('TENANCY_SUBDOMAIN_SUFFIX', '.tenanto.local'),
    'central_domains' => array_filter([
        env('TENANCY_CENTRAL_DOMAIN', 'tenanto.local'),
        'admin.tenanto.local',
    ]),
    'fallback' => env('TENANCY_FALLBACK', 'abort'),
    'fallback_url' => env('TENANCY_FALLBACK_URL', '/'),
    'cache' => [
        'enabled' => env('TENANCY_CACHE_ENABLED', true),
        'ttl' => env('TENANCY_CACHE_TTL', 3600),
        'prefix' => 'tenant_',
    ],
];

Security Considerations

Defense in Depth

Tenanto uses multiple layers of protection:

  1. Global Scope: Automatic query filtering
  2. Middleware: Tenant context validation
  3. Policy: Authorization checks in every action
  4. Route Middleware: Ensures tenant context exists

Never Do This

// WRONG: Bypassing tenant scope in tenant-facing code
$projects = Project::withoutGlobalScopes()->get();

// WRONG: Manual tenant_id without policy check
$project = Project::find($request->project_id);

// WRONG: Trusting route model binding alone
public function show(Project $project) // No policy check!
{
    return $project;
}

Always Do This

// CORRECT: Let global scope handle filtering
$projects = Project::all();

// CORRECT: Use policies for authorization
$this->authorize('view', $project);

// CORRECT: Proper controller method
public function show(Project $project): Response
{
    $this->authorize('view', $project);
    return response()->json($project);
}

Testing Tenant Isolation

Required Tests

Every tenant-aware feature must have isolation tests:

public function test_user_cannot_access_other_tenant_data(): void
{
    $tenantA = Tenant::factory()->create();
    $tenantB = Tenant::factory()->create();

    $projectA = Project::factory()->for($tenantA)->create();

    $userB = User::factory()->for($tenantB)->create();

    $this->actingAs($userB);
    $this->setCurrentTenant($tenantB);

    // Should not find it (global scope)
    $this->assertNull(Project::find($projectA->id));

    // Should return 404 or 403
    $response = $this->getJson("/api/v1/projects/{$projectA->uuid}");
    $response->assertStatus(404); // Or 403
}

Running Isolation Tests

php artisan test --group=tenant-isolation

Cache Management

Clearing Tenant Cache

When tenant data changes:

$resolver = app(TenantResolver::class);
$resolver->clearCache($tenant);

Automatic Cache Invalidation

Consider adding to your Tenant model:

protected static function booted(): void
{
    static::updated(function (Tenant $tenant) {
        app(TenantResolver::class)->clearCache($tenant);
    });
}

Related Documentation