📦 Marketplace⭐ GitHub
📦

monkeyslegion-tenancy

v1.0.0MITDatabase

by Jorge Peraza

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.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 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 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