Upgrade to PRO for Only $50/Yearβ€”Limited-Time Offer! πŸ”₯

SymfonyCon 2025 - MultiTenant

Avatar for Iain Cambridge Iain Cambridge
December 01, 2025
60

SymfonyCon 2025 -Β MultiTenant

Avatar for Iain Cambridge

Iain Cambridge

December 01, 2025
Tweet

Transcript

  1. Background - BillaBear β€’ BillaBear - A Symfony based billing

    system β€’ Started as a standalone version β€’ Later I added a cloud hosted version β€’ The aim to have both use the same basic code base with cloud being modified for multi-tenant β€’ Have the ability to test without having to worry about the multi-tenantancy part
  2. What I Needed To Modify β€’ Console - Commands β€’

    Messenger Component β€’ Scheduler Component β€’ Cache Component
  3. Foundation - Tenant Interfaces #1 interface TenantSwitcher { public function

    switchBySubdomain(string $subdomain): void; public function switchById(string $id): void; } interface TenantProvider { public function getTenant(): Tenant; }
  4. Foundation - Tenant Interfaces #2 interface TenantRepositoryInterface { /** *

    @return \Generator|Tenant[] */ public function getAllActive(): \Generator; }
  5. Console - Commands - Steps β€’ Automatically add an option

    to all commands β€’ Automatically read the value of that option and use it to set the tenant
  6. Commands - Add options automatically class MultiTenantPass implements CompilerPassInterface {

    public function process(ContainerBuilder $container) { $commands = $container->findTaggedServiceIds('console.command'); foreach ($commands as $id => $tagInfo) { $definition = $container->getDefinition($id); $definition->addMethodCall('addOption', ['tenant', null, InputOption::VALUE_REQUIRED]); } } }
  7. Commands - Switch tenant automatically - #1 class MultiTenantCommandListener implements

    EventSubscriberInterface { public function __construct( private TenantSwitcher $tenantSwitcher, ) { } public static function getSubscribedEvents() { return [ ConsoleEvents::COMMAND => 'onCommand', ]; } // ...
  8. \Symfony\Component\Console\Application::doRunCommand // @ line 1090 $this->dispatcher->dispatch($event, ConsoleEvents::COMMAND); if ($event->commandShouldRun()) {

    $exitCode = $command->run($input, $output); } else { $exitCode = ConsoleCommandEvent::RETURN_CODE_DISABLED; }
  9. Commands - Switch tenant automatically - #2 class MultiTenantCommandListener implements

    EventSubscriberInterface { // ... public function onCommand(ConsoleCommandEvent $event) { if (!$event->getInput()->hasOption('tenant')) { return; } $tenant = $event->getInput()->getOption('tenant'); if (!$tenant) { return; } $this->tenantSwitcher->switchBySubdomain($tenant); } }
  10. Messenger - Steps β€’ Create a messenger stamp to declare

    the tenant β€’ Create a middleware that adds the stamp β€’ Create an event subscriber that uses the stamp to set the tenant
  11. Messenger - Stamp class TenantStamp implements StampInterface { public function

    __construct( private string $tenantId, ) { } public function getTenantId(): string { return $this->tenantId; } }
  12. Messenger - Middleware class TenantAssignerMiddleware implements MiddlewareInterface { public function

    __construct( private TenantProvider $tenantProvider, ) { } public function handle(Envelope $envelope, StackInterface $stack): Envelope { $last = $envelope->last(TenantStamp::class); if (null !== $last) { // Set somewhere else so we'll respect it. return $stack->next()->handle($envelope, $stack); } $tenant = $this->tenantProvider->getTenant(); $tenantId = (string) $tenant->getId(); $envelope = $envelope->with(new TenantStamp($tenantId)); return $stack->next()->handle($envelope, $stack); } }
  13. Messenger - Event Subscriber #1 class WorkerMessageReceivedSubscriber implements EventSubscriberInterface {

    public function __construct( private TenantSwitcher $tenantSwitcher ) { } public static function getSubscribedEvents() { return [ WorkerMessageReceivedEvent::class => 'onWorkerReceived', ]; }
  14. \Symfony\Component\Messenger\Worker::handleMessage // @ Line 154 $event = new WorkerMessageReceivedEvent($envelope, $transportName);

    $this->eventDispatcher?->dispatch($event); $envelope = $event->getEnvelope(); if (!$event->shouldHandle()) { return; } // Handle message ...
  15. Messenger - Event Subscriber #2 public function onWorkerReceived( WorkerMessageReceivedEvent $event,

    ) { $envelope = $event->getEnvelope(); $tenantStamp = $envelope->last(TenantStamp::class); if (!$tenantStamp instanceof TenantStamp) { return; } $id = $tenantStamp->getTenantId(); $this->tenantSwitcher->switchById($id); } }
  16. Scheduler - Steps 1. Create an interface to show it’s

    a Scheduler message 2. Create a messenger stamp to show it’s been created for a tenant 3. Create a messenger middleware to create a message for each tenant 4. Create a messenger middleware to switch to the correct tenant
  17. Scheduler - Duplicate Messages Middleware #1 class ScheduleMiddleware implements MiddlewareInterface

    { public function __construct( private MessageBusInterface $messageBus, private TenantRepositoryInterface $tenantRepository, ) { } public function handle(Envelope $envelope, StackInterface $stack): Envelope {
  18. Scheduler - Duplicate Messages Middleware #2 if (!$envelope->getMessage() instanceof ScheduleMessageInterface)

    { return $stack->next()->handle($envelope, $stack); } // Check if the message has already been duplicated if (null !== $envelope->last(SchedulerStamp::class)) { return $stack->next()->handle($envelope, $stack); } // ...
  19. Scheduler - Duplicate Messages Middleware #3 // ... $allTenants =

    $this->tenantRepository->getAllActive(); $lastTenant = $allTenants->current(); $allTenants->next(); while ($allTenants->valid()) { $tenant = $allTenants->current(); $allTenants->next(); $duplicateEnvelope = clone $envelope; $duplicateEnvelope = $duplicateEnvelope->with( new SchedulerStamp(), new TenantStamp((string) $tenant->getId()), ); $this->messageBus->dispatch($duplicateEnvelope); }
  20. Scheduler - Duplicate Messages Middleware #4 // ... $duplicateEnvelope =

    $envelope->with( new SchedulerStamp(), new TenantStamp((string) $lastTenant->getId()), ); return $stack->next()->handle( $duplicateEnvelope, $stack, ); } }
  21. Scheduler - Switcher Middleware - #1 class ScheduleSwitcherMiddleware implements MiddlewareInterface

    { public function __construct( private TenantSwitcher $tenantSwitcher, ) { } public function handle(Envelope $envelope, StackInterface $stack): Envelope { // ...
  22. Scheduler - Switcher Middleware - #2 // ... if (!$envelope->getMessage()

    instanceof ScheduleMessageInterface) { return $stack->next()->handle($envelope, $stack); } $tenantStamp = $envelope->last(TenantStamp::class); $this->tenantSwitcher->switchById($tenantStamp->getTenantId()); return $stack->next()->handle($envelope, $stack); } }
  23. Cache - Steps β€’ Create a factory to create cache

    adapters β€’ Create Service definition for factory β€’ Change config to use factory
  24. Cache - Factory class Factory { public function __construct( #[Autowire('%redis_connection%')]

    private string $connection, private \Psr\Log\LoggerInterface $cacheLogger, ) { } public function createApp(): AdapterInterface { $redis = new \Redis(['host' => $this->connection]); $adapter = new RedisAdapter($redis, $this->getNamespace()); $adapter->setLogger($this->cacheLogger); return $adapter; } private function getNamespace() { $request = explode('.', $_SERVER['HTTP_HOST'] ?? 'system')[0]; return sprintf('tenant_%s', $request); } }