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

Maintainers

Package info

github.com/MonkeysCloud/MonkeysLegion-Tenancy

Homepage

pkg:composer/monkeyscloud/monkeyslegion-tenancy

Statistics

Installs: 0

Dependents: 0

Suggesters: 1

Stars: 0

Open Issues: 0

1.0.0 2026-05-16 04:39 UTC

This package is auto-updated.

Last update: 2026-05-16 05:06:56 UTC


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.0
  • psr/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/: Default Tenant entity with property hooks and lifecycle methods
  • Enum/: Backed enums (TenancyMode, TenantStatus, ResolutionStrategy)
  • Event/: Lifecycle and resolution events (7 total)
  • Infrastructure/: Tenant-aware adapters for cache, queue, storage, session
  • Lifecycle/: Provisioning and management (TenantManager)
  • Middleware/: PSR-15 middleware for automatic resolution
  • Migration/: Central table creation and per-tenant migration runner
  • Resolver/: 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 protectionTenantScope::validate() prevents accessing rows from other tenants
  • Path traversal preventionTenantStorageAdapter rejects ../ paths
  • Suspended tenant blocking — middleware returns 503 for inactive tenants
  • Automatic cleanuptry/finally ensures driver disconnect on all code paths
  • Scoped executionTenantContext::run() guarantees context restoration

Testing

composer test
composer phpstan

License

MIT © MonkeysCloud