Lock in $30 Savings on PRO—Offer Ends Soon! ⏳

Multi-Tenantising The Symfony Components

Avatar for Iain Cambridge Iain Cambridge
June 12, 2025
170

Multi-Tenantising The Symfony Components

Avatar for Iain Cambridge

Iain Cambridge

June 12, 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 interface TenantSwitcher { public function switchBySubdomain

    (string $subdomain): void; public function switchById(string $id): void; } interface TenantProvider { public function getTenant(): Tenant; } interface TenantRepositoryInterface { /** * @return \Generator|Tenant[] */ public function getAllActive(): \Generator; }
  4. Console - Commands - Steps • Automatically add an option

    to all commands • Automatically read the value of that option and use it to set the tenant
  5. Commands - Add options automatically u se Symfony\Component\Console\Input\InputOption; use Symfony\Component\DependencyInjection

    \Compiler\CompilerPassInterface ; use Symfony\Component\DependencyInjection \ContainerBuilder ; 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]); } } }
  6. Commands - Switch tenant automatically use Symfony\Component\Console\ConsoleEvents ; use Symfony\Component\Console\Event\ConsoleCommandEvent

    ; use Symfony\Component\EventDispatcher \EventSubscriberInterface ; class MultiTenantCommandListener implements EventSubscriberInterface { public function __construct ( private TenantSwitcher $tenantSwitcher , ) { } public static function getSubscribedEvents () { return [ ConsoleEvents ::COMMAND => 'onCommand' , ]; } public function onCommand(ConsoleCommandEvent $event) { if (!$event->getInput()->hasOption('tenant')) { return; } $tenant = $event->getInput()->getOption('tenant'); if (!$tenant) { return; } $this->tenantSwitcher->switchBySubdomain ($tenant); } }
  7. 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
  8. Messenger - Stamp use Symfony\Component\Messenger\Stamp\StampInterface ; class TenantStamp implements StampInterface

    { public function __construct( private string $tenantId, ) { } public function getTenantId(): string { return $this->tenantId; } }
  9. Messenger - Middleware use Symfony\Component\Messenger\Envelope; use Symfony\Component\Messenger\Middleware\MiddlewareInterface ; use Symfony\Component\Messenger\Middleware\StackInterface

    ; 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); } }
  10. Messenger - Event Subscriber use Symfony\Component\EventDispatcher \EventSubscriberInterface ; use Symfony\Component\Messenger\Event\WorkerMessageReceivedEvent

    ; class WorkerMessageReceivedSubscriber implements EventSubscriberInterface { public function __construct ( private TenantSwitcher $tenantSwitcher ) { } public static function getSubscribedEvents () { return [ WorkerMessageReceivedEvent ::class => 'onWorkerReceived' , ]; } 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); } }
  11. 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
  12. Scheduler - Duplicate Messages Middleware - Part 1 use Symfony\Component\Messenger\Envelope;

    use Symfony\Component\Messenger\MessageBusInterface ; use Symfony\Component\Messenger\Middleware\MiddlewareInterface ; use Symfony\Component\Messenger\Middleware\StackInterface ; class ScheduleMiddleware implements MiddlewareInterface { public function __construct( private MessageBusInterface $messageBus, private TenantRepositoryInterface $tenantRepository , ) { } public function handle(Envelope $envelope, StackInterface $stack): Envelope { 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); } // ...
  13. Scheduler - Duplicate Messages Middleware - Part 2 // ...

    $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 ); } $duplicateEnvelope = $envelope->with(new SchedulerStamp (), new TenantStamp((string) $lastTenant->getId())); return $stack->next()->handle($duplicateEnvelope , $stack); } }
  14. Scheduler - Switcher Middleware use Symfony\Component\Messenger\Envelope; use Symfony\Component\Messenger\Middleware\MiddlewareInterface ; use

    Symfony\Component\Messenger\Middleware\StackInterface ; class ScheduleSwitcherMiddleware implements MiddlewareInterface { public function __construct( private TenantSwitcher $tenantSwitcher , ) { } public function handle(Envelope $envelope, StackInterface $stack): Envelope { 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); } }
  15. Cache - Steps • Create a factory to create cache

    adapters • Create Service definition for factory • Change config to use factory
  16. Cache - Factory use Symfony\Component\Cache\Adapter\AdapterInterface ; use Symfony\Component\Cache\Adapter\RedisAdapter ; use

    Symfony\Component\DependencyInjection \Attribute\Autowire; 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); } }
  17. Cache - Service parameters: redis_connection : '%env(string:REDIS_URL)%' services: cache.tenant.redis :

    class: Symfony\Component\Cache\Adapter\RedisAdapter factory: ['@Cloud\Cache\Factory' , 'createSystem' ] public: true