Skip to content

Architecture Overview

A contributor's map of the bundle: how it is structured, what happens during a request, and where each responsibility lives.

Event-Driven Bootstrapper Model

When a tenant is identified, a chain of bootstrappers reconfigure Symfony services for that tenant. This model is borrowed from stancl/tenancy for Laravel, adapted to Symfony idioms: bundles, compiler passes, kernel events, and Symfony service tags.

The design principle: zero boilerplate for the application developer. No manual service decoration, no conditional checks scattered through business code. The bundle handles everything at the framework boundary.

Full Request Lifecycle

Request arrives
    |
Symfony Router (kernel.request priority 32)
    |
TenantContextOrchestrator (kernel.request priority 20)
    |
    +-- ResolverChain.resolve(request)
    |       |
    |       +-- HostResolver      (priority 30) — subdomain / custom domain
    |       +-- HeaderResolver    (priority 20) — X-Tenant-ID header
    |       +-- QueryParamResolver (priority 10) — ?_tenant= query param
    |       +-- ConsoleResolver   (priority 10) — --tenant= CLI flag
    |
    +-- TenantContext.setTenant(tenant)
    |
    +-- BootstrapperChain.boot(tenant)
    |       |
    |       +-- DatabaseSwitchBootstrapper  — closes the tenant connection; next query
    |       |                                  lazy-reconnects through TenantAwareDriver
    |       |                                  (see architecture/dbal-middleware.md)
    |       +-- DoctrineBootstrapper        — clears EntityManager identity map
    |       +-- SharedDriver                — injects TenantContext into TenantAwareFilter
    |       +-- (any custom bootstrapper)
    |
    +-- dispatch(TenantBootstrapped)
    |
Controller / Application Code runs in full tenant context
    |
kernel.terminate
    |
    +-- BootstrapperChain.clear()  [REVERSE order of boot()]
    |       |
    |       +-- (custom bootstrapper).clear()
    |       +-- SharedDriver.clear()
    |       +-- DoctrineBootstrapper.clear()
    |       +-- DatabaseSwitchBootstrapper.clear()
    |
    +-- TenantContext.clear()
    |
    +-- dispatch(TenantContextCleared)

Priority ordering

TenantContextOrchestrator runs at priority 20 — after the Router (priority 32) but before the Security firewall (priority 8). This ensures the tenant is identified before any security decision is made, which allows tenant-specific security configurations to function correctly.

Key Design Principles

TenantContext is a zero-dependency value holder. It has no constructor parameters and no circular dependencies. Any service that needs to know the current tenant injects TenantContext directly.

Bootstrappers are registered via DI tags. Any class implementing TenantBootstrapperInterface is auto-tagged as tenancy.bootstrapper via registerForAutoconfiguration in TenancyBundle::loadExtension(). No manual wiring needed.

clear() runs in reverse order of boot(). BootstrapperChain::clear() uses array_reverse($this->bootstrappers) to guarantee that the last bootstrapper to boot() is the first to clear(). This mirrors LIFO cleanup in stack-like architectures.

Doctrine dependencies are optional. Every Doctrine and Messenger import in src/ is guarded by class_exists() or interface_exists(). The bundle works without any ORM installed. See Coding Standards for the guard pattern.

Strict mode is ON by default. strict_mode: true means the bundle throws TenantMissingException when a #[TenantAware] entity is queried with no active tenant. This is a security default — a data leak across tenants is a security incident.

Namespace Map

The src/ directory contains 40 files across 18 namespaces:

Namespace Contents
Tenancy\Bundle TenancyBundle, TenantInterface — bundle entry point and core contract
Tenancy\Bundle\Attribute TenantAware — PHP attribute that marks Doctrine entities for tenant scoping
Tenancy\Bundle\Bootstrapper BootstrapperChain, DatabaseSwitchBootstrapper, DoctrineBootstrapper, TenantBootstrapperInterface
Tenancy\Bundle\Cache TenantAwareCacheAdapter — decorates cache.app with per-tenant sub-namespace
Tenancy\Bundle\Command TenantMigrateCommand, TenantRunCommand — CLI commands for tenant operations
Tenancy\Bundle\Context TenantContext — zero-dependency value holder for the active tenant
Tenancy\Bundle\DBAL TenantDriverMiddleware, TenantAwareDriver — DBAL 4 Doctrine\DBAL\Driver\Middleware that merges tenant params at connect() time
Tenancy\Bundle\DependencyInjection\Compiler BootstrapperChainPass, ResolverChainPass, MessengerMiddlewarePass — the three compiler passes
Tenancy\Bundle\Driver SharedDriver, TenantDriverInterface — isolation driver abstraction
Tenancy\Bundle\Entity Tenant — the landlord-side Doctrine entity
Tenancy\Bundle\Event TenantResolved, TenantBootstrapped, TenantContextCleared — PSR-14 events
Tenancy\Bundle\EventListener TenantContextOrchestrator, EntityManagerResetListener — kernel event handlers
Tenancy\Bundle\Exception TenantNotFoundException, TenantInactiveException, TenantMissingException
Tenancy\Bundle\Filter TenantAwareFilter — Doctrine SQL filter for shared-DB mode
Tenancy\Bundle\Messenger TenantStamp, TenantSendingMiddleware, TenantWorkerMiddleware — context preservation across async boundaries
Tenancy\Bundle\Provider DoctrineTenantProvider, TenantProviderInterface — tenant lookup
Tenancy\Bundle\Resolver HostResolver, HeaderResolver, QueryParamResolver, ConsoleResolver, ResolverChain, TenantResolverInterface
Tenancy\Bundle\Testing InteractsWithTenancy — PHPUnit trait for clean per-test tenant context

Compiler Passes

Three compiler passes wire the bundle at container compilation time:

BootstrapperChainPass — Collects all services tagged tenancy.bootstrapper (using PriorityTaggedServiceTrait) and calls BootstrapperChain::addBootstrapper() for each. Also removes tenancy.doctrine_bootstrapper when no Doctrine EntityManager is present.

ResolverChainPass — Collects all services tagged tenancy.resolver, sorts by priority (highest first using PriorityTaggedServiceTrait), and calls ResolverChain::addResolver() for each.

MessengerMiddlewarePass — Prepends TenantSendingMiddleware and TenantWorkerMiddleware to every configured Messenger bus. Guarded by interface_exists(MessageBusInterface::class) so it does nothing when Messenger is not installed. Registered at priority 1 to run before Symfony's own MessengerPass (priority 0).

Two Isolation Drivers

The bundle ships two optional isolation strategies. Both implement TenantBootstrapperInterface and TenantDriverInterface:

database_per_tenant (default) — DatabaseSwitchBootstrapper::boot() and clear() both call $connection->close() on the tenant DBAL connection. The bundle's TenantDriverMiddleware (tagged doctrine.middleware with connection: tenant) wraps the tenant connection's driver; DBAL's lazy reconnect path re-enters TenantAwareDriver::connect(), which merges the active tenant's getConnectionConfig() over the placeholder params on every fresh socket. Each tenant has its own SQLite / MySQL / PostgreSQL database. Requires tenancy.database.enabled: true in the bundle config. See architecture/dbal-middleware.md for the full pipeline.

shared_dbSharedDriver injects the active TenantContext into the TenantAwareFilter Doctrine SQL filter on boot(). Entities annotated with #[TenantAware] automatically have a tenant_id = :tenantId WHERE clause appended to all queries. Cannot be combined with database.enabled: true.

Deep Dives

For more detail on specific subsystems: