MonkeysLegion Tenancy v2
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
FeatureStatusThree Isolation ModesSingle-DB (tenant_id scoping), Schema-per-Tenant, Database-per-TenantFive Resolution StrategiesDomain, Subdomain, HTTP Header, URL Path, Query Parameter + ChainTenant ContextStatic per-request holder with scoped run() executionEntity Scoping#[BelongsToTenant] attribute, automatic WHERE injection, cross-tenant protectionPSR-15 MiddlewareAuto-resolution → status check → driver activation → cleanupLifecycle ManagementCreate, suspend, activate, delete with schema/DB provisioningTenant-Aware CacheTransparent key prefixing: tenant:{id}:Tenant-Aware QueuePer-tenant queue names, payload enrichment, context restorationTenant-Aware StoragePath scoping: tenants/{key}/ with traversal protectionTenant-Aware SessionSession key prefixing for shared infrastructureMigration OrchestrationPer-tenant migrations, auto-generated central tenants tableEvent System7 lifecycle + resolution events for audit/telemetryPHP 8.4 NativeProperty 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 HostResolved Tenant Keyacme.example.comacmeglobex.example.comglobexexample.comnull (central context)nested.sub.example.comnull (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:
EventDispatched WhenTenantResolvedTenant identified from request (includes resolver class)TenantNotFoundResolution failed (includes host + path)TenantSwitchedContext changed from one tenant to anotherTenantCreatedNew tenant provisionedTenantSuspendedTenant suspended (includes reason)TenantActivatedTenant reactivatedTenantDeletedTenant 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 tenantsPath traversal prevention —
TenantStorageAdapterrejects../pathsSuspended tenant blocking — middleware returns 503 for inactive tenants
Automatic cleanup —
try/finallyensures driver disconnect on all code pathsScoped execution —
TenantContext::run()guarantees context restoration
Testing
composer test
composer phpstan
License
MIT © MonkeysCloud