Database-per-tenant is the safe way to build a B2B SaaS on Symfony. Every workspace gets its own PostgreSQL database, its own queue and its own bucket, so a missing WHERE tenant_id = ? cannot leak anything and a big customer can be moved to a bigger server by changing a DSN. The usual price is a deployment per tenant: a php-fpm pool, a couple of Messenger workers and a scheduler, always on, whether the tenant is used or not.
That price is fine while every tenant pays. It stops being fine the day you add a free plan. In our case two out of three workspaces had no paid plan, most of them had not been opened in weeks and their always-on processes were the main reason the nodes ran out of memory. Cost grew with the number of tenants, not with their usage and at a few hundred free tenants it would have eaten half the revenue of the paying ones.
The standard fixes trade the isolation away. Merging everything into one database with a tenant_id column means a data migration for every table and a leak waiting for every forgotten filter. A schema per tenant keeps the data apart but needs a search_path per connection, which breaks behind a transaction pooler and still runs N migrations.
This article takes the third road: keep a database per tenant, share the processes. One Symfony 8.1 deployment serves every free tenant placed on it. Each HTTP request and each queued message names its tenant and three small adapters follow that name: a DBAL connection that reconnects to the tenant's database, a cache decorator that namespaces by tenant and a Messenger transport that reads every tenant's stream. The same image still runs a dedicated deployment for paying tenants, with one environment variable flipped. Controllers, handlers and repositories do not change.
We'll build it step by step on a small demo, measure what a tenant switch costs and talk about when you should not do this.
Prerequisites
- PHP 8.4, Symfony 8.1 (everything here works from 7.3, which introduced cache sub-namespaces), Doctrine ORM 3 with DBAL 4.
- PostgreSQL 16 and Redis 7 (Docker is enough),
symfony/redis-messengerfor the tenant streams andsymfony/doctrine-messengerfor the failure transport.
1. The tenant and where the code reads it
Everything that used to come from a dedicated deployment's environment is derived from one uuid:
// src/Tenant/Tenant.php final readonly class Tenant { public string $uuid; public function __construct(string $uuid) { if (!UuidV4::isValid($uuid)) { throw new \InvalidArgumentException(sprintf('"%s" is not a tenant uuid', mb_substr($uuid, 0, 60))); } $this->uuid = strtolower($uuid); } public function dbName(): string { return 'tenant_'.str_replace('-', '_', $this->uuid); } public function cacheNamespace(): string { return 't_'.$this->uuid; } public function stream(string $suffix = ''): string { return 'tenant-'.$this->uuid.$suffix; } public function pathPrefix(): string { return 'tenant_'.$this->uuid; } } The current tenant lives in a static holder:
// src/Tenant/TenantContext.php final class TenantContext { private static ?Tenant $default = null; private static ?Tenant $current = null; public static function current(): Tenant { return self::$current ?? self::$default ?? throw new NoTenantException('No tenant is set for this request or message'); } public static function isDefault(Tenant $tenant): bool { return self::$default !== null && self::$default->uuid === $tenant->uuid; } public static function set(Tenant $tenant): void { self::$current = $tenant; } public static function clear(): void { self::$current = null; } public static function setDefault(?Tenant $tenant): void { self::$default = $tenant; } } A static holder is unfashionable and here it is the right tool. The database connection and the cache decorator need the tenant below the level where services are injected and the value is per request or per message, not per service. Injecting a "current tenant" service into a hundred constructors would give you a hundred places that captured the wrong tenant the moment a worker handles two tenants in a row.
Which brings us to the one rule everything else depends on. Read the tenant at call time, never in a constructor and never into a property. A worker keeps its services alive across messages and in shared mode consecutive messages belong to different tenants. In the production codebase this rule is a unit test that scans src/ for constructors touching the tenant.
The $default slot is what makes the dedicated deployment work unchanged. current() never falls back to "some" tenant: on a shared deployment with nothing set, it throws. A request that reaches tenant data without a tenant is a bug and a loud NoTenantException is cheaper than a quiet row in the wrong database.
2. Two modes, one codebase
# .env TENANT_MODE=single # dedicated: this deployment is one tenant TENANT_UUID= # that tenant TENANT_PLACEMENT=shared-1 # shared: which placement this deployment serves The kernel sets the default at boot:
// src/Kernel.php public function boot(): void { parent::boot(); SingleTenantResolver::boot( (string) ($_SERVER['TENANT_MODE'] ?? $_ENV['TENANT_MODE'] ?? 'single'), (string) ($_SERVER['TENANT_UUID'] ?? $_ENV['TENANT_UUID'] ?? ''), ); } SingleTenantResolver::boot() calls TenantContext::setDefault(new Tenant($uuid)) in single mode and setDefault(null) in multi mode. It is the only class that reads TENANT_UUID. A dedicated deployment therefore runs every request, command and message for its own tenant, exactly as it did before this work. A shared deployment has no default and must be told the tenant each time.
3. HTTP: the tenant is the path prefix
A shared deployment addresses a tenant as /tenant_<uuid>/…. The obvious implementation is a route prefix and it is the wrong one: every path() call in Twig and every generateUrl() would need the tenant parameter. Symfony already has a mechanism for "this app lives under a sub-directory": the request's base URL. Make the prefix the base URL and routing sees the path without it while every generated URL keeps it.
// src/Tenant/TenantPathPrefix.php final class TenantPathPrefix { public const string ATTRIBUTE = '_tenant_prefix'; private const string PATTERN = '#^/tenant_([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})(?:/|$)#i'; public static function rebase(Request $request): ?Request { if (!preg_match(self::PATTERN, rawurldecode($request->getPathInfo()), $match) || $request->getBaseUrl() !== '') { return null; } $uuid = strtolower($match[1]); $server = $request->server->all(); $server['SCRIPT_NAME'] = $server['PHP_SELF'] = '/tenant_'.$uuid.'/index.php'; $server['SCRIPT_FILENAME'] = ($server['SCRIPT_FILENAME'] ?? '') !== '' ? $server['SCRIPT_FILENAME'] : 'index.php'; $rebased = $request->duplicate(server: $server); $rebased->attributes->set(self::ATTRIBUTE, $uuid); return $rebased; } } Request::getBaseUrl() derives the base from SCRIPT_NAME, so rewriting SCRIPT_NAME to /tenant_<uuid>/index.php is all it takes. The kernel applies it before anything else sees the request:
// src/Kernel.php public function handle(Request $request, int $type = self::MAIN_REQUEST, bool $catch = true): Response { if ($type === self::MAIN_REQUEST) { $request = TenantPathPrefix::rebase($request) ?? $request; } return parent::handle($request, $type, $catch); } A listener at priority 2048, ahead of the firewall, turns the prefix into the tenant:
// src/Tenant/TenantRequestListener.php #[AsEventListener(event: KernelEvents::REQUEST, priority: 2048)] public function onRequest(RequestEvent $event): void { if (!$event->isMainRequest()) { return; } $request = $event->getRequest(); $prefix = $request->attributes->get(TenantPathPrefix::ATTRIBUTE); if ($this->mode === TenantMode::Single) { if (is_string($prefix) && (!TenantContext::has() || TenantContext::current()->uuid !== $prefix)) { throw new NotFoundHttpException('No such tenant here'); } return; } TenantContext::clear(); if (!is_string($prefix)) { if (preg_match('#^/health(/|$)#', $request->getPathInfo())) { return; } throw new NotFoundHttpException('No tenant in the path'); } TenantContext::set($this->resolver->resolve($prefix)); } #[AsEventListener(event: KernelEvents::TERMINATE)] public function onTerminate(TerminateEvent $event): void { TenantContext::clear(); } The rules are deliberately strict. On a dedicated deployment a prefix naming another tenant is a 404; a request without a prefix is fine, because its ingress strips it. On a shared deployment a request without a prefix is a 404 unless it is the health check and MultiTenantResolver answers 404 for a tenant that is not placed here. The resolver has no fallback branch.
Run the demo and ask the shared instance who it is for two tenants:
$ curl -s http://127.0.0.1:8770/tenant_11111111-…/whoami {"tenant":"11111111-…","database":"tenant_11111111_1111_4111_8111_111111111111","basePath":"/tenant_11111111-…","notesUrl":"/tenant_11111111-…/notes"} $ curl -s http://127.0.0.1:8770/tenant_22222222-…/whoami {"tenant":"22222222-…","database":"tenant_22222222_2222_4222_8222_222222222222","basePath":"/tenant_22222222-…","notesUrl":"/tenant_22222222-…/notes"} Same process, same controller, no route parameter. notesUrl comes from generateUrl('notes_list') and carries the prefix because it is the base path. The database field is the next section.
4. The database follows the tenant
Doctrine's DBAL has a wrapper_class option: your own subclass of Doctrine\DBAL\Connection, used for the connection service. DBAL calls connect() before every query, which gives us one place to pick the database:
// src/Tenant/TenantConnection.php class TenantConnection extends Connection { private ?string $connectedTo = null; protected function connect(): DriverConnection { $params = $this->getParams(); $target = $this->databaseFor($params['dbname'] ?? null); if ($this->_conn !== null) { if ($target === $this->connectedTo) { return $this->_conn; } if ($this->isTransactionActive()) { throw new \LogicException(sprintf('Tenant changed to %s inside a database transaction', TenantContext::current()->uuid)); } $this->close(); } $params['dbname'] = $target; try { $connection = $this->_conn = $this->driver->connect($params); } catch (Driver\Exception $e) { throw $this->convertException($e); } $this->connectedTo = $target; if (!$this->isAutoCommit()) { $this->beginTransaction(); } return $connection; } private function databaseFor(?string $configured): ?string { $tenant = TenantContext::current(); return TenantContext::isDefault($tenant) ? $configured : $tenant->dbName(); } } # config/packages/doctrine.yaml doctrine: dbal: url: '%env(resolve:DATABASE_URL)%' wrapper_class: App\Tenant\TenantConnection Three things matter here.
The dedicated deployment is untouched. Its own tenant is the default, so databaseFor() returns the configured dbname from DATABASE_URL. Any other tenant gets tenant_<uuid> on the same server.
A tenant change inside a transaction is a bug, so it throws. Reconnecting would silently commit nothing and lose the first half of the work. If you hit this, the code that opened the transaction is holding a tenant across a boundary it should not cross.
There is one EntityManager. When the tenant changes, the worker clears it (next section), so no entity of tenant A survives into tenant B's message. Metadata and query caches stay global, because the schema is identical in every tenant database. The result cache, if you use one, goes into the tenant namespace like any other application cache.
Post a note to each tenant and read them back:
$ curl -s -X POST -H 'Content-Type: application/json' -d '{"body":"hello from A"}' http://127.0.0.1:8770/tenant_1111…/notes $ curl -s -X POST -H 'Content-Type: application/json' -d '{"body":"hello from B"}' http://127.0.0.1:8770/tenant_2222…/notes $ curl -s http://127.0.0.1:8770/tenant_1111…/notes [{"id":2,"body":"hello from A","processedAt":"2026-11-09T20:36:49+00:00","processedBy":"MacBookPro16.local:95790"}] $ curl -s http://127.0.0.1:8770/tenant_2222…/notes [{"id":1,"body":"hello from B","processedAt":"2026-11-09T20:36:49+00:00","processedBy":"MacBookPro16.local:95790"}] The ids overlap because the rows live in different databases. processedBy is the same worker pid for both and that worker is the subject of section 6.
5. The cache follows the tenant
Application caches hold tenant data: computed stats, rendered fragments, the scheduler's last-run state. Since Symfony 7.3 every cache adapter implements NamespacedPoolInterface, so a decorator can hand each tenant a sub-namespace of the same pool:
// src/Tenant/TenantAwareCache.php final class TenantAwareCache implements AdapterInterface, CacheInterface, NamespacedPoolInterface, ResettableInterface { /** @var array<string, AdapterInterface&CacheInterface&NamespacedPoolInterface> */ private array $byTenant = []; public function __construct(private readonly AdapterInterface&CacheInterface&NamespacedPoolInterface $inner) { } private function pool(): AdapterInterface { $tenant = TenantContext::current(); if (TenantContext::isDefault($tenant)) { return $this->inner; } return $this->byTenant[$tenant->uuid] ??= $this->inner->withSubNamespace($tenant->cacheNamespace()); } public function get(string $key, callable $callback, ?float $beta = null, ?array &$metadata = null): mixed { return $this->pool()->get($key, $callback, $beta, $metadata); } // getItem(), save(), deleteItem(), … all delegate to $this->pool() public function reset(): void { foreach ([$this->inner, ...array_values($this->byTenant)] as $pool) { if ($pool instanceof ResettableInterface) { $pool->reset(); } } $this->byTenant = []; } } # config/services.yaml services: App\Tenant\TenantAwareCache: decorates: cache.app arguments: ['@.inner'] Again the dedicated deployment's keys do not change, so its caches stay warm through the deploy that introduces this.
One trap with tag-aware pools. Symfony stores tags in the pool's root namespace, shared by every sub-namespace. If tenant A invalidates the tag product_list, it drops tenant B's product_list entries too. Prefix the tags with the tenant namespace as well as the keys. The demo's pool has no tags; the production one does and this is where its decorator differs from the one above.
Caches that are platform-wide by design, such as a revoked-tokens list that other services write, must not be decorated. Decide per pool and write the decision down next to the pool.
6. Workers: one process, every tenant's stream
This is where most multitenancy write-ups stop and where most of the cost sits. A dedicated deployment runs its own messenger:consume processes against its own Redis stream. We want a shared worker to read every placed tenant's stream, without changing the senders: other services push into tenant-<uuid> streams today and they should keep doing exactly that.
The transport keeps its name. Only its DSN changes with the mode:
# config/packages/messenger.yaml framework: messenger: failure_transport: failed transports: async: dsn: '%env(MESSENGER_TRANSPORT_DSN)%' # redis://…/tenant-<uuid> or tenant-multi://…?kind=main options: { consumer: '%env(MESSENGER_CONSUMER_NAME)%', redeliver_timeout: 300, claim_interval: 30000 } retry_strategy: { max_retries: 2, delay: 1000 } failed: 'doctrine://default?queue_name=failed' routing: 'App\Message\NoteCreated': async 'App\Message\SlowJob': async Routing, #[AsMessageHandler(fromTransport: 'async')] and the messenger:consume async command line are identical in both modes. A custom transport factory claims the tenant-multi:// scheme and builds this:
// src/Tenant/Messenger/MultiTenantTransport.php (abridged) final class MultiTenantTransport implements TransportInterface, KeepaliveReceiverInterface, MessageCountAwareInterface { /** @var array<string, array{RedisReceiver, RedisSender}> */ private array $byTenant = []; private ?\Redis $redis = null; private int $next = 0; public function get(int $fetchSize = 1): iterable { $tenants = $this->placement->tenants(); $count = count($tenants); for ($i = 0; $i < $count; ++$i) { $uuid = $tenants[($this->next + $i) % $count]; if ($this->fairUse !== null && !$this->Source:https://dev.to/mattleads/stop-paying-for-idle-tenants-database-per-tenant-symfony-81-on-one-shared-deployment-2k1cRelated posts
Introducing Percona Database Service (PDS): What Happens When an Engineer Just Wants to Try Something
How H6.ai builds AI-powered financial automation with Lakebase Postgres
aws rds: la base de datos relacional gestionada en la nube
Node.js Marketplace Webhooks: Rate-Limit Queues, Retries, and Delivery Guarantees
Stripe fulfilment from two paths: three idempotency keys and a test for a hanging email provider
