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
tenants
id, slug, domain, settings
projects
tenant_id, name, archived state, settings
tasks
tenant_id, project_id, title, status
Isolation is enforced by global scopes, middleware, policies, and dedicated tenant isolation tests. Admin-only queries can bypass scope explicitly.
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
Schema Overview
Core tenancy entities
| Entity | Important columns | Purpose |
|---|---|---|
tenants |
id, uuid, slug, domain, settings, billing fields |
Top-level tenant record |
users |
id, tenant_id, email, is_tenant_owner |
Tenant users and central admin users |
teams |
id, tenant_id, name, settings |
Team grouping within a tenant |
team_user |
team_id, user_id, role |
Pivot between teams and users |
Product module entities
| Entity | Important columns | Purpose |
|---|---|---|
projects |
id, tenant_id, uuid, name, is_archived, settings |
Example project module |
tasks |
id, tenant_id, project_id, title, status, priority, assigned_to, created_by |
Example task module |
subscriptions |
id, tenant_id, name, stripe_id, stripe_status, stripe_price |
Laravel Cashier subscriptions per tenant |
Operational entities
| Entity | Important columns | Purpose |
|---|---|---|
licenses |
id, tenant_id, key, tier, status, activation_count, metadata |
Optional licensing layer |
onboarding_progress |
id, tenant_id, user_id, step, data, completed |
Onboarding wizard persistence |
personal_access_tokens |
id, tokenable_type, tokenable_id, abilities, last_used_at |
Sanctum API tokens |
Rule of thumb: if a table is tenant-scoped, it carries tenant_id and is protected by the tenant scope plus policy checks.
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);
});
}