Skip to content

DI Compilation Pipeline

The Tenancy Bundle wires all services at container compile time using three compiler passes and two extension hooks (loadExtension, prependExtension). No manual DI configuration is required from users — everything is automatic.

Overview

Container Build
    ├── prependExtension()       ← prepend Doctrine entity mappings + filter config
    ├── loadExtension()          ← register base services + conditional services
    │                                 (registers TenantDriverMiddleware on the tenant
    │                                  connection when database.enabled: true)
    └── Compiler Passes (BeforeOptimization phase)
            ├── BootstrapperChainPass       (collects tenancy.bootstrapper tags)
            ├── ResolverChainPass           (collects tenancy.resolver tags)
            ├── CacheDecoratorContractPass  (asserts tenant cache decorators cover
            │                                every Symfony\\* interface on cache.app)
            └── MessengerMiddlewarePass     (priority 1, before MessengerPass)

All four compiler passes are registered in TenancyBundle::build():

public function build(ContainerBuilder $container): void
{
    parent::build($container);
    $container->addCompilerPass(new BootstrapperChainPass());
    $container->addCompilerPass(new ResolverChainPass());
    $container->addCompilerPass(new CacheDecoratorContractPass());
    if (interface_exists(MessageBusInterface::class)) {
        $container->addCompilerPass(
            new MessengerMiddlewarePass(),
            PassConfig::TYPE_BEFORE_OPTIMIZATION,
            1  // priority — before MessengerPass at 0
        );
    }
}

CacheDecoratorContractPass is a compile-time guard (added in v0.2 / Phase 15-01): it inspects every Tenancy-owned cache decorator and its decorated target and throws LogicException at container compile if any Symfony\\* interface exposed by the decorated service is missing from the decorator's declared interface list. This prevents a regression of the Fix #5 class of bug (decorator that does not implement CacheInterface, for example) from ever re-landing.


BootstrapperChainPass

Tag: tenancy.bootstrapper

BootstrapperChainPass collects all services tagged tenancy.bootstrapper, sorts them by tag priority (descending), and registers them with BootstrapperChain via addMethodCall:

public function process(ContainerBuilder $container): void
{
    // Remove DoctrineBootstrapper if Doctrine ORM is not installed
    if ($container->hasDefinition('tenancy.doctrine_bootstrapper')
        && !$container->has('doctrine.orm.entity_manager')) {
        $container->removeDefinition('tenancy.doctrine_bootstrapper');
    }

    $definition = $container->findDefinition(BootstrapperChain::class);
    $bootstrappers = $this->findAndSortTaggedServices('tenancy.bootstrapper', $container);

    foreach ($bootstrappers as $bootstrapper) {
        $definition->addMethodCall('addBootstrapper', [$bootstrapper]);
    }
}

The result is that BootstrapperChain::$bootstrappers is populated in priority order at compile time. At runtime, boot() just iterates the array — no tag lookups or reflection.

Auto-tagging is configured in loadExtension():

$builder->registerForAutoconfiguration(TenantBootstrapperInterface::class)
    ->addTag('tenancy.bootstrapper');

Any service implementing TenantBootstrapperInterface is automatically tagged tenancy.bootstrapper with no additional config. Users can override the priority via:

# config/services.yaml
App\Bootstrapper\MyBootstrapper:
    tags:
        - { name: tenancy.bootstrapper, priority: 50 }

ResolverChainPass

Tag: tenancy.resolver

ResolverChainPass mirrors BootstrapperChainPass for resolvers:

// src/DependencyInjection/Compiler/ResolverChainPass.php (simplified)
public function process(ContainerBuilder $container): void
{
    $definition = $container->findDefinition(ResolverChain::class);

    // Build allowed FQCN set from config short-names (e.g. 'host', 'header')
    $allowedFqcns = null;
    if ($container->hasParameter('tenancy.resolvers')) {
        $allowedFqcns = [];
        foreach ($container->getParameter('tenancy.resolvers') as $name) {
            if (isset(self::BUILT_IN_RESOLVER_MAP[$name])) {
                $allowedFqcns[] = self::BUILT_IN_RESOLVER_MAP[$name];
            }
        }
    }

    $resolvers = $this->findAndSortTaggedServices('tenancy.resolver', $container);

    foreach ($resolvers as $resolver) {
        $serviceId = (string) $resolver;
        if (null !== $allowedFqcns) {
            $fqcn = $container->findDefinition($serviceId)->getClass() ?? $serviceId;
            // Built-in resolvers must be in the allowed list
            if (in_array($fqcn, self::BUILT_IN_RESOLVER_MAP, true)
                && !in_array($fqcn, $allowedFqcns, true)) {
                continue; // Skip — not in config
            }
            // Custom resolvers (not in built-in map) always pass through
        }
        $definition->addMethodCall('addResolver', [$resolver]);
    }
}

The pass reads the tenancy.resolvers config parameter to determine which built-in resolvers are active. Built-in resolvers not listed in the config are skipped. Custom resolvers (any class implementing TenantResolverInterface that is not in the BUILT_IN_RESOLVER_MAP) always pass through the filter — they cannot be accidentally disabled by configuration. If no tenancy.resolvers parameter exists, all resolvers are added unconditionally (backward compatible).

Built-in resolver priorities (defined in config/services.php):

Resolver Priority Source
HostResolver 30 tenancy.resolver tag
HeaderResolver 20 tenancy.resolver tag
QueryParamResolver 10 tenancy.resolver tag
ConsoleResolver 5 tenancy.resolver tag

Higher priority = runs first. ResolverChain::resolve() returns the first non-null result.

Auto-tagging for user-defined resolvers:

$builder->registerForAutoconfiguration(TenantResolverInterface::class)
    ->addTag('tenancy.resolver');

MessengerMiddlewarePass

Priority: 1 (before Symfony's MessengerPass at priority 0)

Guard: interface_exists(MessageBusInterface::class) — entire pass skipped when Messenger is not installed.

MessengerMiddlewarePass prepends TenantSendingMiddleware and TenantWorkerMiddleware to every Messenger bus's middleware stack.

Why direct parameter modification?

Symfony's FrameworkExtension stores the merged middleware config in container parameters named {busId}.middleware (e.g. messenger.bus.default.middleware). MessengerPass then reads these parameters to build the actual service references.

The middleware array uses performNoDeepMerging() in the Symfony Configuration tree, which means prependExtensionConfig() would overwrite the middleware array instead of prepending to it. The solution is to read and rewrite the parameter directly:

public function process(ContainerBuilder $container): void
{
    $tenancyMiddleware = [
        ['id' => 'tenancy.messenger.sending_middleware'],
        ['id' => 'tenancy.messenger.worker_middleware'],
    ];

    $busIds = array_keys($container->findTaggedServiceIds('messenger.bus'));

    foreach ($busIds as $busId) {
        $paramName = $busId . '.middleware';

        if ($container->hasParameter($paramName)) {
            $existing = $container->getParameter($paramName);
            $container->setParameter($paramName, array_merge($tenancyMiddleware, $existing));
        }
    }
}

Why priority 1?

MessengerPass runs at priority 0 and consumes the {busId}.middleware parameter (builds service references from it, then removes the parameter). If MessengerMiddlewarePass ran at priority 0 or lower, the parameter would already be gone. Running at priority 1 guarantees the pass sees the parameter before MessengerPass consumes it.

The pass includes a fallback path that directly modifies the bus IteratorArgument in case MessengerPass ran first (edge case for non-standard container configurations).


prependExtension() — Doctrine Entity Mappings

prependExtension() runs before loadExtension() and prepends Doctrine configuration. The target path depends on whether database.enabled is set:

database.enabled: false (default — shared-DB or no Doctrine)

$builder->prependExtensionConfig('doctrine', [
    'orm' => [
        'mappings' => $mapping,  // maps to default entity manager
    ],
]);

The bundle's Tenant entity is registered in the default ORM mapping.

database.enabled: true (database-per-tenant with dual-EM)

$builder->prependExtensionConfig('doctrine', [
    'orm' => [
        'entity_managers' => [
            'landlord' => [
                'mappings' => $mapping,  // maps to landlord entity manager only
            ],
        ],
    ],
]);

The Tenant entity is registered only in the landlord entity manager, not the default tenant-switching EM.

driver: shared_db

Additionally, the tenancy_aware SQL filter is registered:

$builder->prependExtensionConfig('doctrine', [
    'orm' => [
        'filters' => [
            'tenancy_aware' => [
                'class' => TenantAwareFilter::class,
                'enabled' => true,
            ],
        ],
    ],
]);

This uses Doctrine's native filter mechanism so the filter participates in Doctrine's query cache.


loadExtension() — Conditional Service Registration

loadExtension() imports the base service definitions and registers conditional services based on configuration:

Always Registered

Service Class Purpose
tenancy.context TenantContext Stateful tenant holder, injected everywhere
tenancy.bootstrapper_chain BootstrapperChain Runs bootstrappers in priority order
tenancy.resolver_chain ResolverChain Runs resolvers in priority order
TenantContextOrchestrator Kernel event listener (autoconfigured)
EntityManagerResetListener Resets EM on TenantContextCleared
tenancy.command.init TenantInitCommand Scaffolds config/packages/tenancy.yaml

When database.enabled: true

Service Purpose
tenancy.database_switch_bootstrapper Calls $connection->close() on boot (DBAL lazy-reconnects through TenantAwareDriver)
tenancy.dbal.tenant_driver_middleware Tagged doctrine.middleware with connection: tenant — merges tenant params at connect() time
DoctrineTenantProvider rewired Reads from doctrine.orm.landlord_entity_manager
tenancy.command.migrate tenancy:migrate command (when doctrine/migrations present)

When driver: shared_db

Service Purpose
tenancy.shared_driver Injects TenantContext into TenantAwareFilter on boot

Mutual Exclusion Guard

The bundle's configure() validates that shared_db and database.enabled: true cannot be combined:

->validate()
    ->ifTrue(fn(array $v) => $v['driver'] === 'shared_db' && $v['database']['enabled'] === true)
    ->thenInvalid('tenancy.driver: shared_db cannot be combined with tenancy.database.enabled: true.')
->end()

Service Dependency Graph

TenantContextOrchestrator
    ├── TenantContext
    ├── BootstrapperChain
    │       ├── DatabaseSwitchBootstrapper → Doctrine\DBAL\Connection (tenant connection)
    │       │       (socket rotation happens via TenantDriverMiddleware on the tenant
    │       │        connection — see architecture/dbal-middleware.md)
    │       ├── SharedDriver → EntityManagerInterface + TenantContext
    │       ├── DoctrineBootstrapper → EntityManagerInterface
    │       └── TenantAwareCacheAdapter → CacheInterface
    ├── EventDispatcher
    └── ResolverChain
            ├── HostResolver → TenantProviderInterface
            ├── HeaderResolver → TenantProviderInterface
            └── ConsoleResolver → TenantProviderInterface

TenantWorkerMiddleware (Messenger)
    ├── TenantContext
    ├── BootstrapperChain
    ├── TenantProviderInterface
    └── EventDispatcher