monkeyscloud / monkeyslegion-tenancy
Enterprise multi-tenant patterns for MonkeysLegion: single-DB, schema-per-tenant, database-per-tenant isolation with domain resolution and tenant-aware infrastructure
Package info
github.com/MonkeysCloud/MonkeysLegion-Tenancy
pkg:composer/monkeyscloud/monkeyslegion-tenancy
Requires
- php: ^8.4
- monkeyscloud/monkeyslegion-core: *
- monkeyscloud/monkeyslegion-database: *
- monkeyscloud/monkeyslegion-di: *
- monkeyscloud/monkeyslegion-entity: *
- monkeyscloud/monkeyslegion-events: *
- monkeyscloud/monkeyslegion-http: *
- psr/http-message: ^2.0
- psr/http-server-handler: ^1.0
- psr/http-server-middleware: ^1.0
Requires (Dev)
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^11.0 || ^12.0
- squizlabs/php_codesniffer: ^3.11
Suggests
- monkeyscloud/monkeyslegion-cache: For tenant-aware cache isolation
- monkeyscloud/monkeyslegion-cli: For tenant management CLI commands
- monkeyscloud/monkeyslegion-files: For tenant-aware storage isolation
- monkeyscloud/monkeyslegion-migration: For tenant migration orchestration
- monkeyscloud/monkeyslegion-queue: For tenant-aware per-tenant queue isolation
README
Enterprise multi-tenant patterns for MonkeysLegion: single-DB with tenant_id scoping, schema-per-tenant, and database-per-tenant. Domain/subdomain identification, tenant-aware cache/queue/storage. Essential for B2B SaaS. Ground-up build for PHP 8.4 with property hooks, backed enums, and zero magic.
Features
| Feature | Status |
|---|---|
| Three Isolation Modes | Single-DB (tenant_id scoping), Schema-per-Tenant, Database-per-Tenant |
| Five Resolution Strategies | Domain, Subdomain, HTTP Header, URL Path, Query Parameter + Chain |
| Tenant Context | Static per-request holder with scoped run() execution |
| Entity Scoping | #[BelongsToTenant] attribute, automatic WHERE injection, cross-tenant protection |
| PSR-15 Middleware | Auto-resolution → status check → driver activation → cleanup |
| Lifecycle Management | Create, suspend, activate, delete with schema/DB provisioning |
| Tenant-Aware Cache | Transparent key prefixing: tenant:{id}: |
| Tenant-Aware Queue | Per-tenant queue names, payload enrichment, context restoration |
| Tenant-Aware Storage | Path scoping: tenants/{key}/ with traversal protection |
| Tenant-Aware Session | Session key prefixing for shared infrastructure |
| Migration Orchestration | Per-tenant migrations, auto-generated central tenants table |
| Event System | 7 lifecycle + resolution events for audit/telemetry |
| PHP 8.4 Native | Property hooks, backed enums, asymmetric visibility |
Requirements
- PHP 8.4 or higher
monkeyscloud/monkeyslegion-database(ConnectionManager)monkeyscloud/monkeyslegion-events(Event dispatching)psr/http-message^2.0psr/http-server-middleware^1.0
Installation
composer require monkeyscloud/monkeyslegion-tenancy
Architecture
┌───────────────────────────────────────────────────────────┐
│ HTTP Request │
└─────────────────────────┬─────────────────────────────────┘
▼
┌───────────────────────────────────────────────────────────┐
│ TenantResolverMiddleware (PSR-15) │
│ ChainResolver: Domain → Subdomain → Header → Path → QP │
└─────────────────────────┬─────────────────────────────────┘
▼
┌──────────────┐ ┌────────────────┐ ┌─────────────────┐
│ TenantContext │ │ TenancyDriver │ │ TenantScope │
│ ::set() │ │ ::connect() │ │ WHERE tenant_id│
└──────────────┘ └────────────────┘ └─────────────────┘
▼
┌───────────────────────────────────────────────────────────┐
│ Application Layer │
│ ┌────────┐ ┌────────┐ ┌─────────┐ ┌─────────┐ │
│ │ Cache │ │ Queue │ │ Storage │ │ Session │ │
│ │Adapter │ │Adapter │ │ Adapter │ │ Adapter │ │
│ └────────┘ └────────┘ └─────────┘ └─────────┘ │
└───────────────────────────────────────────────────────────┘
The package is organized into clear namespaces:
Attribute/: Entity attributes (#[BelongsToTenant])Context/: Per-request tenant holder (TenantContext)Contracts/: Core interfaces (TenantInterface,TenancyDriverInterface,TenantResolverInterface)Driver/: Isolation implementations (SingleDatabaseDriver,SchemaDatabaseDriver,SeparateDatabaseDriver)Entity/: DefaultTenantentity with property hooks and lifecycle methodsEnum/: Backed enums (TenancyMode,TenantStatus,ResolutionStrategy)Event/: Lifecycle and resolution events (7 total)Infrastructure/: Tenant-aware adapters for cache, queue, storage, sessionLifecycle/: Provisioning and management (TenantManager)Middleware/: PSR-15 middleware for automatic resolutionMigration/: Central table creation and per-tenant migration runnerResolver/: Resolution strategies (Domain, Subdomain, Header, Path, QueryParam, Chain)Scope/: Automatic query scoping and entity lifecycle hooks
Configuration
Copy the example config to your application's config directory:
cp vendor/monkeyscloud/monkeyslegion-tenancy/config/tenancy.mlc config/tenancy.mlc
tenancy {
# Isolation mode: single_db, schema, database
mode = ${TENANCY_MODE:-single_db}
# Resolution strategies (comma-separated, tried in order)
resolvers = ${TENANCY_RESOLVERS:-subdomain,header}
# Base domain for subdomain resolution
base_domain = ${APP_DOMAIN:-localhost}
# HTTP header for header-based resolution
header_name = ${TENANCY_HEADER:-X-Tenant-ID}
# Tenant prefix for schema/database naming
tenant_prefix = ${TENANCY_PREFIX:-tenant_}
# Per-tenant queue isolation
queue {
per_tenant = ${TENANCY_QUEUE_PER_TENANT:-true}
name_template = ${TENANCY_QUEUE_TEMPLATE:-tenant_{tenant_key}}
}
# Cache key prefixing
cache {
prefix_template = ${TENANCY_CACHE_PREFIX:-tenant:{tenant_id}:}
}
# Storage path scoping
storage {
path_template = ${TENANCY_STORAGE_PATH:-tenants/{tenant_key}/}
}
# Lifecycle automation
lifecycle {
auto_migrate = ${TENANCY_AUTO_MIGRATE:-true}
auto_seed = ${TENANCY_AUTO_SEED:-true}
backup_on_delete = ${TENANCY_BACKUP_ON_DELETE:-true}
}
}
Isolation Modes
Single Database (single_db)
All tenants share one database. Isolation is achieved via automatic WHERE tenant_id = :current clauses injected by TenantScope.
Best for: SaaS startups, low-to-medium tenant counts, cost-sensitive deployments.
// Mark entities as tenant-scoped #[Entity(table: 'invoices')] #[BelongsToTenant] class Invoice { #[Id] public private(set) int $id; #[Field(type: 'string')] public string $title; // tenant_id column is auto-managed — you don't touch it }
Schema per Tenant (schema)
Each tenant gets a dedicated PostgreSQL schema (or MySQL database). The SchemaDatabaseDriver switches search_path / USE per request.
Best for: Mid-size SaaS, compliance-sensitive industries, moderate isolation needs.
// Automatically switches to tenant schema on each request // PostgreSQL: SET search_path TO "tenant_acme", public // MySQL: USE `tenant_acme`
Database per Tenant (database)
Each tenant gets a fully separate database. The SeparateDatabaseDriver routes to a dedicated ConnectionInterface per tenant.
Best for: Enterprise SaaS, maximum isolation, regulated industries (HIPAA, SOC2).
Tenant Resolution
The middleware resolves tenants by trying resolvers in the order configured:
// 1. Subdomain: "acme.example.com" → tenant key "acme" // 2. HTTP Header: X-Tenant-ID: acme // 3. URL Path: /t/acme/dashboard // 4. Full Domain: custom-domain.com → tenants.domain match // 5. Query Parameter: ?tenant=acme
Example: Subdomain Resolution
With base_domain = "example.com":
| Request Host | Resolved Tenant Key |
|---|---|
acme.example.com |
acme |
globex.example.com |
globex |
example.com |
null (central context) |
nested.sub.example.com |
null (nested not supported) |
Tenant Context
The TenantContext is the central access point for the current tenant:
use MonkeysLegion\Tenancy\Context\TenantContext; // Set by middleware automatically, but can be used manually TenantContext::set($tenant); // Quick access $tenant = TenantContext::get(); // ?TenantInterface $id = TenantContext::id(); // int|string|null $key = TenantContext::key(); // ?string $tenant = TenantContext::require(); // throws if not resolved // Check if (TenantContext::isResolved()) { // Inside a tenant context } // Scoped execution — restores previous context after callback TenantContext::run($otherTenant, function () { // All operations here are scoped to $otherTenant $invoices = $repo->findAll(); // WHERE tenant_id = $otherTenant->getId() }); // Previous tenant context restored here
Entity Scoping
Automatic WHERE Injection
use MonkeysLegion\Tenancy\Scope\TenantScope; // Before your query, apply tenant scoping $result = TenantScope::apply( "SELECT * FROM invoices WHERE status = :status", ['status' => 'paid'], ); // Result: "SELECT * FROM invoices WHERE tenant_id = :__tenant_scope_id AND status = :status" // Params: ['status' => 'paid', '__tenant_scope_id' => 42] $stmt = $conn->query($result['sql'], $result['params']);
Automatic Insert Data
$data = TenantScope::insertData(); // Returns: ['tenant_id' => 42] // Merge into your INSERT data to auto-set the tenant column
Cross-Tenant Validation
// Validates a row belongs to the current tenant — throws on mismatch TenantScope::validate($row);
Entity Lifecycle Listener
use MonkeysLegion\Tenancy\Scope\TenantScopeListener; // Auto-inject tenant_id on INSERT $data = TenantScopeListener::beforeInsert($entity, $data); // Validate before UPDATE/DELETE TenantScopeListener::beforeMutation($entity, $data);
Lifecycle Management
The TenantManager provides a complete provisioning pipeline:
use MonkeysLegion\Tenancy\Lifecycle\TenantManager; use MonkeysLegion\Tenancy\Enum\TenancyMode; $manager = $container->get(TenantManager::class); // Create a new tenant (auto-provisions schema, runs migrations, activates) $tenant = $manager->create( key: 'acme', name: 'Acme Corporation', mode: TenancyMode::Schema, plan: 'enterprise', domain: 'acme.example.com', migrationSqls: [ 'CREATE TABLE invoices (...)', 'CREATE TABLE projects (...)', ], ); // Suspend (e.g., payment overdue) $manager->suspend($tenant, reason: 'Payment overdue — invoice #1234'); // Reactivate $manager->activate($tenant); // Soft delete (sets status = 'deleted') $manager->delete($tenant); // Hard delete (drops schema/DB, removes row) $manager->delete($tenant, hard: true); // List all active tenants $tenants = $manager->all(); // Find by key $tenant = $manager->findByKey('acme');
Auto-Generated Central Table
The tenants table is created automatically by the package:
$migration = $container->get(TenantMigrationRunner::class); $migration->ensureCentralTable();
This creates:
CREATE TABLE tenants ( id INTEGER PRIMARY KEY AUTO_INCREMENT, `key` VARCHAR(64) NOT NULL UNIQUE, name VARCHAR(255) NOT NULL, domain VARCHAR(255) DEFAULT NULL, database_name VARCHAR(128) DEFAULT NULL, schema_name VARCHAR(128) DEFAULT NULL, plan VARCHAR(64) NOT NULL DEFAULT 'free', status VARCHAR(32) NOT NULL DEFAULT 'pending', mode VARCHAR(32) NOT NULL DEFAULT 'single_db', metadata JSON DEFAULT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP );
Tenant-Aware Infrastructure
Cache
use MonkeysLegion\Tenancy\Infrastructure\TenantCacheAdapter; $adapter = $container->get(TenantCacheAdapter::class); $cacheKey = $adapter->key('user:42'); // → "tenant:5:user:42" $tag = $adapter->tag('reports'); // → "tenant_5:reports" $pattern = $adapter->flushPattern(); // → "tenant:5:*"
Queue (Per-Tenant Isolation)
use MonkeysLegion\Tenancy\Infrastructure\TenantQueueAdapter; $adapter = $container->get(TenantQueueAdapter::class); $queueName = $adapter->queueName(); // → "tenant_acme" // Enrich job payload with tenant metadata $payload = $adapter->enrichPayload(['job' => 'SendInvoice', 'invoice_id' => 99]); // → ['job' => 'SendInvoice', 'invoice_id' => 99, '__tenant_id' => 5, '__tenant_key' => 'acme'] // Restore tenant context in the worker $info = $adapter->extractTenantFromPayload($payload); // → ['tenant_id' => 5, 'tenant_key' => 'acme']
Storage (Path Scoping)
use MonkeysLegion\Tenancy\Infrastructure\TenantStorageAdapter; $adapter = $container->get(TenantStorageAdapter::class); $path = $adapter->path('uploads/logo.png'); // → "/app/storage/tenants/acme/uploads/logo.png" $root = $adapter->tenantRoot(); // → "/app/storage/tenants/acme/" // Path traversal protection — this throws: $adapter->path('../../etc/passwd'); // RuntimeException!
Session
use MonkeysLegion\Tenancy\Infrastructure\TenantSessionAdapter; $key = TenantSessionAdapter::key('cart_items'); // → "t5:cart_items" $name = TenantSessionAdapter::sessionName(); // → "MLSESSID_t5"
Events
All lifecycle and resolution events extend MonkeysLegion\Events\Event:
| Event | Dispatched When |
|---|---|
TenantResolved |
Tenant identified from request (includes resolver class) |
TenantNotFound |
Resolution failed (includes host + path) |
TenantSwitched |
Context changed from one tenant to another |
TenantCreated |
New tenant provisioned |
TenantSuspended |
Tenant suspended (includes reason) |
TenantActivated |
Tenant reactivated |
TenantDeleted |
Tenant deleted (includes ID + key) |
// Listen to tenant events for audit/telemetry $dispatcher->listen(TenantResolved::class, function (TenantResolved $event) { $logger->info("Tenant resolved: {$event->tenant->getKey()} via {$event->resolverClass}"); }); $dispatcher->listen(TenantSuspended::class, function (TenantSuspended $event) { $notifier->alertAdmin("Tenant {$event->tenant->getName()} suspended: {$event->reason}"); });
Middleware Setup
Register the middleware in your HTTP pipeline:
// In your middleware configuration $pipeline->pipe(TenantResolverMiddleware::class); // The middleware automatically: // 1. Resolves tenant via ChainResolver // 2. Returns 404 if no tenant found // 3. Returns 503 if tenant is suspended // 4. Sets TenantContext // 5. Activates the tenancy driver (connect) // 6. Adds tenant to request attributes // 7. Cleans up after response (disconnect + context reset)
Tenant Entity
The default Tenant entity uses PHP 8.4 property hooks:
use MonkeysLegion\Tenancy\Entity\Tenant; use MonkeysLegion\Tenancy\Enum\TenancyMode; // Factory creation $tenant = Tenant::create('acme', 'Acme Corp', TenancyMode::Schema, 'enterprise'); // Property hooks — computed on access $tenant->isActive; // bool (delegates to status->isOperational()) $tenant->isSuspended; // bool $tenant->status; // TenantStatus enum $tenant->mode; // TenancyMode enum $tenant->displayName; // name or key fallback // Lifecycle actions $tenant->activate(); $tenant->suspend(); $tenant->archive(); $tenant->markDeleted(); // Metadata $tenant->setMetadata('max_users', 50); $tenant->getMetadataValue('max_users'); // 50
Security Posture
- Cross-tenant protection —
TenantScope::validate()prevents accessing rows from other tenants - Path traversal prevention —
TenantStorageAdapterrejects../paths - Suspended tenant blocking — middleware returns 503 for inactive tenants
- Automatic cleanup —
try/finallyensures driver disconnect on all code paths - Scoped execution —
TenantContext::run()guarantees context restoration
Testing
composer test
composer phpstan
License
MIT © MonkeysCloud