Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings
Discussion options

Dear community. First of all I want to express my appriciation for the developers of Symfony. I'm using it for many years and helps me to develop new features pretty quick.

Current Scenario

Currently I have a multi-tenant (SaaS) REST API. Each tenant has its own database, but they all have the exact same schema. For each tenant I have an .env file (e.g. .env.tenant1.local, .env.tenant.env). Each tenant has it own domainname and, so also a VHost within Apache. Within that VHost I override the APP_ENV so that each domain read the correct env file. I have a shell script that iterates over all the tenants to execute the Doctrine migrations (e.g. APP_ENV=tenant1 php bin/console doctrine:migrations:migrate)

The Problem

I have to request SSL certificates for each domain. Also using the Symfony Messenger component, with for example RabbitMQ, means I have to run a process for each tenant. With five tenants that is okay, but the amount of tenants is growing each year. So I'm thinking of moving to one domain for the API and determine the tenant based on a header, e.g. X-Tenant-ID. However that means I need to switch the database connection on runtime and that is where I got stuck. I know I can configure more connections in the configuration of Doctrine, but that means a redeploy for each new tenant, which I don't want.

What I've Tried

I have tried multiple solutions myself (compiler passes and stuff) which worked half. Recently I used ChatGPT Codex and it gave a working solution, but I want to confirm if this is the Symfony way. What it basically did was:

  • It created a new Application class for the console, and added a required input, the tenant ID. Within bin/console it uses this new Application.
Application.php
final class Application extends BaseApplication
{
    public function __construct(KernelInterface $kernel)
    {
        parent::__construct($kernel);

        $this->getDefinition()->addOption(new InputOption(
            'tenant',
            null,
            InputOption::VALUE_REQUIRED,
            'Tenant ID for tenant-aware database access.'
        ));
    }
}
  • Created two subscribers that catches kernel.request and console.command, reads the header of input and stores it within a TenantContext service.

  • It created a TenantDriverMiddleware that implements the Doctrine\DBAL\Driver\Middleware interface from Doctrine. Within the wrap method it reads the tenant from the context, gets the database url from a config file and calls the parent::connect with the updated params.

TenantDriverMiddleware.php
#[AsMiddleware(connections: ['default'], priority: 100)]
final readonly class TenantDriverMiddleware implements Middleware
{
    private DsnParser $dsnParser;

    public function __construct(private TenantContext $tenantContext)
    {
        $this->dsnParser = new DsnParser();
    }

    public function wrap(Driver $driver): Driver
    {
        $tenantContext = $this->tenantContext;
        $dsnParser = $this->dsnParser;

        return new class ($driver, $tenantContext, $dsnParser) extends AbstractDriverMiddleware {
            public function __construct(
                Driver $driver,
                private readonly TenantContext $tenantContext,
                private readonly DsnParser $dsnParser,
            ) {
                parent::__construct($driver);
            }

            public function connect(
                #[SensitiveParameter]
                array $params,
            ): DriverConnection {
                $tenantConfig = $this->tenantContext->getTenantConfigOrDefault();
                $resolved = $this->dsnParser->parse($tenantConfig->databaseUrl);
                $resolvedParams = array_merge($params, $resolved);

                if (isset($params['dbname_suffix'], $resolvedParams['dbname'])) {
                    $resolvedParams['dbname'] .= $params['dbname_suffix'];
                }

                unset($resolvedParams['url']);

                return parent::connect($resolvedParams);
            }
        };
    }
}
  • It also created some middleware for the Messenger component and created a TenantStamp.
  • There is a default database connection in the .env file, because Symfony needs it.

It works perfectly. My endpoints work with the X-Tenant-ID header, and I can run migations and other commands by running php bin/console doctrine:migrations:migrate --tenant=tenant1. It also works with the VichUploaderBundle which I have installed.

The Question

Is the solution Codex created good practice? Will I run into caching or connection issues on production? Or are there other ways of solving this problem? I know I could just put everything in one database and add a tenant_id column to my tables, but that is not an option. I'm really hoping that there is a good way in Symfony to fix this problem.

Best regards,
Jan Doornbos from The Netherlands

You must be logged in to vote

Replies: 2 comments · 2 replies

Comment options

hey, the approach Codex gave you is actually solid and pretty much the recommended way to do this in modern Symfony/Doctrine. the DBAL middleware layer is exactly where runtime connection switching belongs since Doctrine DBAL 3.x introduced that extension point. you're on the right track.

a few things to watch for in production though:

metadata cache - since all your tenants share the same schema this should be fine. but if you ever end up in a situation where you're rolling out migrations tenant by tenant (not all at once), cached metadata from one tenant could be stale for another. if that ever becomes a concern, you can namespace the Doctrine metadata cache key by tenant id. for now with identical schemas its a non-issue.

connection lifecycle - PHP-FPM is actually your friend here. each request gets its own process, connections are request-scoped and torn down at the end. theres no risk of leaking one tenant's connection state to another request. just make sure you're NOT using PDO::ATTR_PERSISTENT connections, because persistent connections are pooled per worker and could serve the wrong tenant's connection on the next request.

messenger workers - the TenantStamp approach is the correct way to propagate tenant context to async jobs. one thing to double check: since messenger workers are long-running processes, make sure you have the doctrine_ping_connection and doctrine_close_connection middleware enabled in your messenger bus config (Symfony adds these by default iirc). this ensures the connection is reset between messages so your middleware re-resolves the right tenant DB each time.

entity manager state - in long-running messenger consumers, call $entityManager->clear() between messages if you see any weird identity map issues. again Symfony's messenger integration should handle this but worth keeping in mind.

one alternative you might consider down the line: instead of reading tenant DB urls from a config file, store them in a central "control plane" database. that way adding a new tenant is just an insert, no config file changes or redeployments needed. your middleware would hit the control plane DB to resolve tenant -> connection string (with aggressive caching obviously).

overall though, what you have is clean and production-ready. nice work getting it set up 👍

You must be logged in to vote
1 reply
@jandoornbos
Comment options

Thanks a lot! This gives me the confidence to pursue this direction. Also, I appreciate the additional tips you’ve provided. They’re really helpful.

Comment options

Why not one entity manager for each tenant ?
https://symfony.com/doc/current/doctrine/multiple_entity_managers.html

And make a wrapper of the doctrine ManagerRegistry to fetch the desired entity manager based on the tenant name or id

You must be logged in to vote
1 reply
@jandoornbos
Comment options

But that means I have to update my Symfony project (the Doctrine config) everytime a new tenant is added? Because that is not what I want.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Category
🙏
Q&A
Labels
None yet
3 participants
Morty Proxy This is a proxified and sanitized view of the page, visit original site.