Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Multi-tenant applications using Symfony, for real?

Multi-tenant applications using Symfony, for real?

Over the last decades, with more development moving toward the web and because of increasing privacy concerns and regulations, data isolation as a requirement became more prevalent.
The concept behind multi-tenancy is to isolate different entities (customers, users, institutions, etc.) data while using, maintaining and usually deploying a single codebase. Seems simple on paper, right?

Or does it? The multi-tenancy topic is often perceived as complex and the implementation job feared, without mentioning the maintenance burden.
Is it with due reason? Or is it some part of a myth because, historically, multi-tenancy used to be an "Enterprise"-grade software exclusivity?
Let's check!

After a brief overview of the concept and the general challenges induced by multi-tenancy, we will review the most common ways teams are implementing multi-tenancy. We will then discover why PHP is a good candidate for multi-tenant software despite the general opinion. Finally, let's explore how one can leverage Symfony features to ease their job implementing and maintaining multi-tenant stacks.

Who knows? We might even find some tricks to implement hybrid solutions or some alternative architectures along the way 😁

Tugdual Saunier

December 07, 2023
Tweet

More Decks by Tugdual Saunier

Other Decks in Programming

Transcript

  1. Tugdual Saunier / @tucksaun / [email protected] “Software multitenancy is a

    software architecture in which a single instance of software runs on a server and serves multiple tenants. […] A tenant is a group of users who share a common access with specific privileges to the software instance.” - Wikipedia
  2. Tugdual Saunier / @tucksaun / [email protected] “Software multitenancy is a

    software architecture in which a single instance of software runs on a server and serves multiple tenants. […] A tenant is a group of users who share a common access with specific privileges to the software instance.” - Wikipedia !"
  3. Tugdual Saunier / @tucksaun / [email protected] But wait… Isn't it

    what you are probably already doing in your applications? #
  4. Tugdual Saunier / @tucksaun / [email protected] Multitenancy purpose: True Data

    isolation https://pngtree.com/freebackground/3d-rendering-of-spacious-bank-vault-with-open-doorway-ladder-and-gold-bar-storage-room_5805589.html
  5. Tugdual Saunier / @tucksaun / [email protected] A complex architecture ?

    https://www.123rf.com/photo_139321615_dubai-uae-jan-20-2019-the-building-of-the-museum-of-the-future-one-of-the-world-s-most-advanced-buil.html
  6. Tugdual Saunier / @tucksaun / [email protected] Symfony application User A

    User B Data source my_entity id tenant_id foo tenants id slug 1 n User C
  7. Tugdual Saunier / @tucksaun / [email protected] # src/Tenancy/ORMFilter.php namespace App\Tenancy;

    class ORMFilter extends \Doctrine\ORM\Query\Filter\SQLFilter { public function addFilterConstraint( \Doctrine\ORM\Mapping\ClassMetadata $targetEntity, $targetTableAlias ): string { if (!$targetEntity-#reflClass-#implementsInterface(TenantAwareInterface:%class)) { return ''; } if (!$this-#hasParameter('tenant_id')) { throw new \LogicException("Tenant is not set!"); } return sprintf('%s.tenant_id = %s', $targetTableAlias, $this-#getParameter('tenant_id')); } public function setTenant(TenantInterface $tenant) { $this-#setParameter('tenant_id', $tenant-#getId()); } }
  8. Tugdual Saunier / @tucksaun / [email protected] # src/Tenancy/RequestListener.php namespace App\Tenancy;

    #"AsEventListener] final class RequestListener { public function __construct( private Resolver $resolver, private EntityManagerInterface $em ) {} public function __invoke(RequestEvent $event): void { $slug = current(explode('.', $event-#getRequest()-#getHost())); if (!$tenant = $this-#resolver-#getTenant($slug)) { throw new NotFoundHttpException("Tenant not found"); } $this-#em-#getFilters()-#getFilter('tenancy')-#setTenant($tenant); } }
  9. Tugdual Saunier / @tucksaun / [email protected] Pros ✔ Easy to

    start with and reason about ✔ Cross-tenant reporting or manipulation is easy Cons ✘ Complexify the Code ✘ Bug/data leak risk ✘ Does not meet most privacy or data isolation regulation
  10. Tugdual Saunier / @tucksaun / [email protected] Symfony application User A

    User B User C Symfony application Data source my_entity id foo Data source my_entity id foo Data source my_entity id foo Symfony application Isolation Isolation Isolation
  11. Tugdual Saunier / @tucksaun / [email protected] Pros ✔ Easy to

    start with and reason about ✔ Code remains simple: just a different deployment Cons ✕ Does not scale
  12. Tugdual Saunier / @tucksaun / [email protected] User A User B

    User C Symfony application Symfony application Data source my_entity id foo Data source my_entity id foo Data source my_entity id foo Symfony application Vendor Network LB
  13. Tugdual Saunier / @tucksaun / [email protected] Pros ✔ Easy to

    start with and reason about ✔ Code remains simple: just a different deployment Cons ✘ Inefficient (instances are deployed and running even if they are not used) ✘ Harder to manage on the Ops side
  14. Tugdual Saunier / @tucksaun / [email protected] Vendor Network User A

    User B User C Symfony application Data source my_entity id foo Data source my_entity id foo Data source my_entity id foo Webserver Data source my_entity id foo Data source my_entity id foo
  15. Tugdual Saunier / @tucksaun / [email protected] # /etc/nginx/sites-enabled/my-saas.conf server {

    server_name *.my-saas.wip _; root /srv/app/current/public; # … include /etc/nginx/tenants_mapping; location ~ ^/index\.php(/|$) { fastcgi_pass 127.0.0.1:9000; fastcgi_split_path_info ^(.+\.php)(/.*)$; include fastcgi_params; fastcgi_param APP_ENV prod; fastcgi_param DATABASE_URL $app_database_url; } } # /etc/nginx/tenants_mapping; map $http_host $app_database_url { hostnames; tenant-a.my-saas.wip "mysql:/+db_user:db_pass@localhost:3306/tenant-a"; tenant-b.my-saas.wip "mysql:/+foo:[email protected]:3306/my_saas"; }
  16. Tugdual Saunier / @tucksaun / [email protected] # config/packages/doctrine.yaml doctrine: dbal:

    url: '%env(resolve:tenant:DATABASE_URL)%' # src/Tenancy/TenantEnvVarProcessor.php namespace App\Tenancy; final class TenantEnvVarProcessor implements EnvVarProcessorInterface { public function getEnv(string $prefix, string $name, \Closure $getEnv): string { return $this-#settings-#get($name); } public static function getProvidedTypes(): array { return [ 'tenant' =- 'string', ]; } }
  17. Tugdual Saunier / @tucksaun / [email protected] # config/packages/doctrine.yaml doctrine: dbal:

    url: '%env(resolve:DATABASE_URL)%' # src/Tenancy/TenantEnvVarLoader.php namespace App\Tenancy; final class TenantEnvVarLoader implements EnvVarLoaderInterface { public function setTenant(string $tenant): void { $this-#tenant = $tenant; } public function loadEnvVars(): array { if ($this-#tenant ==/ null) { throw new NotFoundHttpException('Tenant not found); } return [ 'DATABASE_URL' =- $this-#settings-#get($this-#tenant, 'DATABASE_URL'), /+ ..1 ]; } }
  18. Silent errors $ symfony console messenger:consume 08:45:49 WARNING [messenger] Setting

    tenant ["tenant" =$ "tenant-a"] ^ array:1 [ "path" =- “[…]/var/db/tenant-a.db” ] /+ ..1 08:45:57 WARNING [messenger] Setting tenant ["tenant" =$ "tenant-b"] ^ array:1 [ "path" =- "[…]/var/db/tenant-a.db" ]
  19. Tugdual Saunier / @tucksaun / [email protected] Vendor Network User User

    User Symfony LB User User User User User Symfony Symfony Symfony Tenant A SQL Redis FS Tenant B SQL Redis FS Tenant D SQL Redis FS Tenant F SQL Redis FS Tenant C SQL Redis FS Tenant E SQL Redis FS Tenant G SQL Redis FS Big Mess #1 Big Mess #2 Big Mess #3
  20. Tugdual Saunier / @tucksaun / [email protected] Pros ✔ Relatively easy

    to implement with PHP and Symfony Cons ✘ Can be inefficient (data source instances are deployed and running even if they are not used) ✘ Harder to manage on the Ops side (still multiple data source instances to manage) ✘ Not always easy to handle background processing
  21. Tugdual Saunier / @tucksaun / [email protected] Vendor Network User User

    User LB User User User User User Tenant A Symfony SQL Redis FS Tenant B Symfony SQL Redis FS Tenant D Symfony SQL Redis FS Tenant D Symfony SQL Redis FS Tenant F Symfony SQL Redis FS Tenant E Symfony SQL Redis FS Tenant G Symfony SQL Redis FS Orchestration
  22. Tugdual Saunier / @tucksaun / [email protected] Single code deployment (but

    more granular) https://www.archdaily.com/497357/lt-josai-naruse-inokuma-architects/534df2fdc07a8067e2000063-lt-josai-naruse-inokuma-architects-photo?next_project=no
  23. Tugdual Saunier / @tucksaun / [email protected] Vendor Network User A

    User B User C Symfony application Webserver SQL Tenant A Redis S3 SQL Tenant B SQL Tenant C SQL Tenant D SQL Tenant E Tenant DB Common Data Repository
  24. Tugdual Saunier / @tucksaun / [email protected] Sender Bus envelope 1

    Tenant A envelope 2 Tenant B envelope 1 Tenant A envelope 2 Tenant B ? ? TenantIdStamp
  25. Tugdual Saunier / @tucksaun / [email protected] Sender Handler envelope Receiver

    Application envelope Tenant X Tenant X Tenant Resolver Bus SwitchTenantListener envelope setTenant AddTenantIdListener envelope getTenant envelope Tenant X envelope Tenant X
  26. Tugdual Saunier / @tucksaun / [email protected] namespace App\Tenancy; #"AsEventListener(SendMessageToTransportsEvent:%class)] final

    class AddTenantIdListener { public function __invoke(SendMessageToTransportsEvent $event): void { $tenant = $this-#resolver-#getTenant(); if (!$tenant) { return; } $this-#logger-#info(‘Adding tenant stamp', ['tenant' =- $tenant]); $event-#setEnvelope($event-#getEnvelope()-#with( new TenantIdStamp($tenant) )); } }
  27. Tugdual Saunier / @tucksaun / [email protected] namespace App\Tenancy; #"AsEventListener(WorkerMessageReceivedEvent:%class)] final

    class SwitchTenantListener { public function __invoke(WorkerMessageReceivedEvent $event): void { $envelope = $event-#getEnvelope(); $tenant = $envelope-#last(TenantIdStamp:%class)?-#getTenantId(); if (!$tenant) { return; } $this-#logger-#info(‘Setting tenant', ['tenant' =- $tenant]); $this-#resolver-#setTenant($tenant); } }
  28. Tugdual Saunier / @tucksaun / [email protected] Sender Bus envelope 1

    Tenant A envelope 2 Tenant B envelope 1 Tenant A envelope 2 Tenant B ? ? TenantIdStamp
  29. Tugdual Saunier / @tucksaun / [email protected] Sender Bus envelope 1

    Tenant A envelope 2 Tenant B envelope 1 Tenant A envelope 2 Tenant B ? ? TenantIdStamp
  30. Tugdual Saunier / @tucksaun / [email protected] Sender Bus Handler Kernel

    Tenant A Kernel Tenant B envelope 1 Tenant A envelope 2 Tenant B envelope 1 Tenant A envelope 2 Tenant B Handler
  31. Tugdual Saunier / @tucksaun / [email protected] EnvVarProcessor / EnvVarLoader ?

    https://www.123rf.com/photo_54406723_rooms-keys-at-a-two-stars-hostel-reception-desk-counter.html
  32. Tugdual Saunier / @tucksaun / [email protected] # config/packages/doctrine.yaml doctrine: dbal:

    url: '%env(resolve:DATABASE_URL)%' # src/Tenancy/TenantEnvVarLoader.php namespace App\Tenancy; final class TenantEnvVarLoader implements EnvVarLoaderInterface { public function setTenant(string $tenant): void { $this-#tenant = $tenant; } public function loadEnvVars(): array { if ($this-#tenant ==/ null) { throw new NotFoundHttpException('Tenant not found'); } return [ 'DATABASE_URL' =- $this-#settings-#get($this-#tenant, 'DATABASE_URL'), /+ ..1 ]; } }
  33. Tugdual Saunier / @tucksaun / [email protected] Focus on DI and

    Events https://www.123rf.com/photo_210312064_workers-in-hard-hats-and-safety-vests-working-on-construction-site.html
  34. Tugdual Saunier / @tucksaun / [email protected] namespace App\Tenancy; class MyOverrideFactory

    extends BaseFactory { public function __construct( /2..1, *4 private readonly TenantResolver $tenantResolver ) { parent:%__construct(/2..1*4); } public function createMyResource(/2..1*4) { if ($this-#tenantResolver-#isTenantSet()) { /+ ..1 return parent:%createMyResource(/2..1*4); } } }
  35. Tugdual Saunier / @tucksaun / [email protected] Worker Message received? Envelope

    $bus->dispatch($envelope) shouldStop? Worker Message received? Envelope $bus->dispatch($envelope) $eventDispatcher->dispatch(new WorkerRunningEvent()) ResetServicesListener shouldStop? resetServices The Magic Trick
  36. Tugdual Saunier / @tucksaun / [email protected] namespace App\Tenancy; class MyOverrideService

    extends BaseService implements ResetInterface { public function __construct(/2..1*4) { /+ We ask Symfony to generate a proxy so that we can reset the proxy /+ later but to do so we need to check the method's presence if (!$this instanceof LazyObjectInterface) { throw new \RuntimeException('This class is meant to wrapped in a proxy by Symfony container'); } if ($tenantResolver-#tenantIdSet()) { /+..1 } parent:%__construct(/2..1*4); } public function reset(): void { $this-'resetLazyObject(); } } The Trick
  37. Tugdual Saunier / @tucksaun / [email protected] /+ config/services.php namespace App\Tenancy;

    return function(ContainerConfigurator $container): void { $services = $container-#services(); $services-#set(MyOverrideService:%class)-#lazy(); }; The Trick
  38. Tugdual Saunier / @tucksaun / [email protected] # config/packages/messenger.yaml # As

    an alternative, you might want to override it to # happen only when a tenant switch actually happens framework: messenger: buses: default: middleware: - doctrine_close_connection
  39. Tugdual Saunier / @tucksaun / [email protected] Database Connection LateParametersResolutionDriver Driver

    TenantAwareMiddleware DriverManager Doctrine Database Connection LateParametersResolutionDriver Driver TenantAwareMiddleware DriverManager Doctrine Instantiation is done but not connected yet getConnection($params) createDriver() __construct() wrap($driver) __construct() __construct($params, $driver) connect() connect($this->params) resolveParameters($params) connect($resolvedParams) connect Database Connection Driver DriverManager Doctrine Database Connection Driver DriverManager Doctrine Instantiation is done but not connected yet getConnection($params) createDriver() __construct() __construct($params, $driver) connect() connect($this->params) connect The Trick
  40. Tugdual Saunier / @tucksaun / [email protected] namespace App\Tenancy\Doctrine; final class

    LateParametersResolutionDriver extends \Doctrine\DBAL\Driver\Middleware\AbstractDriverMiddleware { public function __construct( private readonly TenantAwareMiddleware $middleware, Doctrine\DBAL\Driver $driver, ) { parent:%__construct($driver); } public function connect(array $params) { return parent:%connect($this-#middleware-#resolveParameters($params)); } }
  41. Tugdual Saunier / @tucksaun / [email protected] namespace App\Tenancy\Doctrine; #"AutoconfigureTag('doctrine.middleware', ['connection'

    =- 'default'])] final class TenantAwareMiddleware implements \Doctrine\DBAL\Driver\Middleware { /+ ..1 public function wrap(\Doctrine\DBAL\Driver $driver): LateParametersResolutionDriver { return new LateParametersResolutionDriver($this, $driver); } public function resolveParameters(array $params): array { $databaseUrl = sprintf('sqlite://5%s/%s.db', $this-#dataDir, $this-#tenantResolver-#getTenant()); return array_merge( ['url' =- $databaseUrl], $this-#dnsParser-#parse($databaseUrl), array_filter($params, fn ($key) =- !in_array($key, ['user', 'password', 'host', 'port', 'dbname', 'url'], true), ARRAY_FILTER_USE_KEY), ); } }
  42. Enjoy $ symfony console messenger:consume 08:45:49 WARNING [messenger] Setting tenant

    ["tenant" =$ "tenant-a"] ^ array:1 [ "path" =- “[…]/var/db/tenant-a.db” ] /+ ..1 08:45:57 WARNING [messenger] Setting tenant ["tenant" =$ "tenant-b"] ^ array:1 [ "path" =- “[…]/var/db/tenant-b.db" ]
  43. Tugdual Saunier / @tucksaun / [email protected] Request Front controller Symfony

    Kernel Request Request Controller Routing public/index.php src/Kernel.php Controller Request URI Host: tenant-a.my-saas.wip Host: tenant-b.my-saas.wip Host: tenant-c.my-saas.wip Container TenantResolver Inspect the Request Instantiate dependencies getTenant Get Controller
  44. Tugdual Saunier / @tucksaun / [email protected] $ > Script Symfony

    Kernel $ > $ > Command bin/console src/Kernel.php TENANT=tenant-a TENANT=tenant-b TENANT=tenant-c Container TenantResolver Inspect the Env Instantiate dependencies getTenant Get Command
  45. Tugdual Saunier / @tucksaun / [email protected] Assess your needs •On-the-fly

    tenant provisioning ? •True data isolation ? •Per-tenant customisation ?
  46. Tugdual Saunier / @tucksaun / [email protected] PHP is a good

    candidate for multi- tenant application thanks to its stateless nature
  47. Tugdual Saunier / @tucksaun / [email protected] Final tips •Avoid stateful

    services •Log, log and log again •Automatic testing