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:
- Simplicity: One database to manage, backup, and maintain
- Cost-effective: No need for multiple database instances
- Easy queries: Cross-tenant reports possible for admins
- Scalable: Supports thousands of tenants efficiently
┌─────────────────────────────────────────────────┐
│ 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:
- Subdomain:
acme.tenanto.com→ tenant slug = "acme" - Custom Domain:
app.acme.com→ mapped to tenant in database
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
- initializeTenantAware(): Captures current tenant when job is created
- Serialization: Tenant model is serialized with job payload
- TenantAwareMiddleware: Restores tenant context when job executes
- Cleanup: Previous context is restored after job completes
Jobs That Don't Need TenantAware
- Jobs operating on ALL tenants (like
CleanupExpiredDemosJob) - Jobs that explicitly query without tenant scope
- Notification classes (they carry the Tenant model directly)
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:
- Global Scope: Automatic query filtering
- Middleware: Tenant context validation
- Policy: Authorization checks in every action
- 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);
});
}