Symfony Doctrine Multi-Tenant Database Architecture: Shared Schema vs Schema-Per-Tenant vs DB-Per-Tenant — a 2026 Decision Matrix
The multi-tenancy decision is made once. Unlike most architectural choices where you can iterate your way to something better, choosing between shared schema, schema-per-tenant, and DB-per-tenant on a Symfony/Doctrine stack is the kind of call that gets baked into 500,000 lines of code before you realise you picked wrong. At 5,000 tenants, the cost of migrating between strategies is measured in months — not sprints.
This guide is for engineering teams at European B2B SaaS companies who are either making this decision for the first time or starting to feel the friction of an earlier choice. The three patterns are well-documented in the abstract; what is less documented is how they interact with the specific pressures that Symfony shops face in 2026: Doctrine ORM's connection model, GDPR audit requirements that now carry real enforcement teeth, and the PostgreSQL connection-pool headroom that determines whether your infrastructure cost stays linear.
The Three Patterns, Stated Plainly
Before the decision matrix, a concise statement of what each model actually means in a Doctrine context — because the naming is inconsistently used across the ecosystem.
Shared schema (discriminator column): All tenants share the same PostgreSQL database and schema. A tenant_id column is added to every tenant-scoped table. Doctrine Filters enforce row-level isolation at the ORM layer. One database connection pool serves all tenants. Migrations run once and affect all tenants simultaneously.
Schema-per-tenant (one schema, one cluster): All tenants reside on the same PostgreSQL cluster, but each tenant has a dedicated schema (e.g., tenant_7a3f.users, tenant_7a3f.orders). An EntityManager factory instantiates a separate EntityManager per tenant, each bound to its schema. PgBouncer sessions can be partitioned per schema. Migrations run per tenant, scoped to a single schema.
DB-per-tenant (one database per tenant): Each tenant gets a dedicated PostgreSQL database, often on the same cluster for smaller setups but potentially on separate RDS or managed Postgres instances for enterprise customers. Connection strings are stored and resolved at runtime. This is the most operationally complex and the most isolated.
The Doctrine EntityManager-Per-Tenant Factory Pattern
Understanding the schema-per-tenant and DB-per-tenant patterns requires understanding how Doctrine's EntityManager is instantiated, because the factory pattern is the linchpin of both.
In a standard Symfony application, a single doctrine.orm.entity_manager service is defined in config/packages/doctrine.yaml and shared across all requests. For multi-tenancy, you bypass this. Instead, you build a factory service that resolves the current tenant from the request context (a subdomain, a JWT claim, an HTTP header) and returns an EntityManager configured for that tenant's connection.
// src/Doctrine/TenantEntityManagerFactory.php
final class TenantEntityManagerFactory
{
public function __construct(
private readonly TenantResolver $tenantResolver,
private readonly Connection $defaultConnection,
private readonly Configuration $doctrineConfig,
) {}
public function getEntityManager(): EntityManager
{
$tenant = $this->tenantResolver->current();
$params = $this->defaultConnection->getParams();
$params['dbname'] = $tenant->databaseName(); // DB-per-tenant
// OR for schema-per-tenant:
// $params['search_path'] = $tenant->schemaName();
$connection = DriverManager::getConnection($params, $this->doctrineConfig);
return new EntityManager($connection, $this->doctrineConfig);
}
}
The factory is then used as a service that replaces direct injection of EntityManagerInterface in repositories. This is the architectural kernel that makes both non-shared-schema strategies viable in Symfony — and it is also where connection-pool pressure originates, which we return to below.
Automated Migrations Per Tenant Schema
One of the most underestimated operational costs of any non-shared-schema strategy is deployment-time migration execution. In the shared-schema model, you run php bin/console doctrine:migrations:migrate once and you are done. In schema-per-tenant or DB-per-tenant, you run it N times — once per tenant.
At 50 tenants this is a bash loop. At 5,000 tenants it is a migration queue with retry logic, tenant locking, rollback signalling, and an alerting surface for partial failures.
A minimal approach for schema-per-tenant:
// src/Command/MigrateAllTenantsCommand.php
#[AsCommand(name: 'app:migrations:migrate-all-tenants')]
final class MigrateAllTenantsCommand extends Command
{
protected function execute(InputInterface $input, OutputInterface $output): int
{
foreach ($this->tenantRepository->findAll() as $tenant) {
$em = $this->emFactory->getEntityManagerForTenant($tenant);
try {
$migrator = $this->buildMigrator($em->getConnection());
$migrator->migrate(new MigrateToLatestCalculation());
$output->writeln("Migrated: {$tenant->getId()}");
} catch (\Throwable $e) {
$this->logger->error("Migration failed for tenant {$tenant->getId()}: {$e->getMessage()}");
// Decide: continue or halt deployment
}
}
return Command::SUCCESS;
}
}
The decision of whether to continue or halt on a failed tenant migration is a business decision disguised as a code comment. Halting is safer but means one broken tenant blocks a release for all others. Continuing means you must track schema version drift across tenants and handle code that must be compatible with both the old and new schema simultaneously — the same challenge database-level blue-green deployments face.
For teams above 200 tenants, running migrations synchronously from a deployment pipeline will exceed timeouts. You need asynchronous migration workers, a status table, and health checks that block traffic rollover until all tenants are at the expected schema version.
The Decision Matrix
The following compares the three strategies across the dimensions that matter most for European B2B SaaS in 2026.
GDPR Data Isolation
| Dimension | Shared schema | Schema-per-tenant | DB-per-tenant |
|---|---|---|---|
| Logical isolation | ORM filter (bypassable) | Schema boundary | Database boundary |
| Physical isolation | None | None (same cluster) | Yes (separate instance) |
| PITR restore granularity | Full cluster only | Full cluster only | Per-tenant |
| Right-to-erasure execution | Complex multi-table DELETE | DROP SCHEMA CASCADE | DROP DATABASE |
| Enterprise DPA suitability | Low | Medium | High |
The GDPR pressure point in 2026 is no longer just right-to-erasure. Enterprise customers with their own DPOs are asking for contractual guarantees about logical and physical data separation. A shared-schema answer to "are our records physically separated from other customers?" is always "no" — which rules it out for regulated buyers in FinTech, healthcare, and public sector regardless of how strong the access controls are.
Schema-per-tenant satisfies most SMB and mid-market buyers. DB-per-tenant is what enterprise and regulated-industry customers increasingly require.
Migration Complexity as Schema Evolves
| Dimension | Shared schema | Schema-per-tenant | DB-per-tenant |
|---|---|---|---|
| Deploy-time migration cost | O(1) | O(N tenants) | O(N tenants) |
| Risk of partial migration state | Low | Medium | High |
| Schema version drift possible | No | Yes | Yes |
| Backward-compatible migrations required | Recommended | Required | Required |
| Rollback complexity | Simple | Complex | Very complex |
Shared schema wins unambiguously on migration complexity. One migration, one result, no drift. For any schema-split strategy, you accept a new class of production problem: tenants on different schema versions running against the same application code. This forces you to always write backward-compatible migrations (never rename, always add-then-remove) and maintain a tenant schema version registry.
Connection Pool Headroom
| Tenant count | Shared schema | Schema-per-tenant | DB-per-tenant |
|---|---|---|---|
| 100 tenants | ~20–50 connections | ~50–150 | ~100–300 |
| 500 tenants | ~20–50 connections | ~250–750 | ~500–1,500 |
| 5,000 tenants | ~20–50 connections | Requires aggressive pooling | Impractical without serverless Postgres |
| PgBouncer required | No | Yes (recommended) | Yes (required) |
This is where schema-per-tenant has a hidden cliff. The EntityManager factory pattern creates new connections per tenant per request unless you layer an aggressive connection pool in front. PgBouncer in transaction mode solves this but introduces SET search_path timing issues with Doctrine — the search path must be set after every connection checkout, which requires a custom postConnect event listener:
// src/Doctrine/SearchPathListener.php
final class SearchPathListener implements EventSubscriberInterface
{
public function postConnect(PostConnectEventArgs $args): void
{
$tenant = $this->tenantResolver->current();
$args->getConnection()->executeStatement(
"SET search_path TO {$tenant->schemaName()}, public"
);
}
public static function getSubscribedEvents(): array
{
return [Events::postConnect => 'postConnect'];
}
}
Without this listener, connections recycled from PgBouncer's pool will carry the previous session's search_path, silently reading the wrong tenant's data. This is the kind of bug that does not show up in testing, passes code review, and causes a GDPR incident in production.
Beyond 500 tenants on schema-per-tenant, the connection arithmetic becomes unsustainable without either Neon's serverless connection pooler or a custom connection multiplexer. DB-per-tenant beyond 500 tenants requires horizontal sharding of tenant databases across multiple clusters — a DevOps investment only justified for enterprise SaaS with contractual uptime and isolation requirements.
Point-in-Time Restore Granularity
PITR granularity is an operational detail that only matters when something goes wrong — at which point it matters enormously.
Shared schema: Restoring the cluster restores all tenants simultaneously. A botched migration that corrupts data for one tenant requires restoring the entire cluster and replaying transactions — measured in hours for large datasets, affecting every customer.
Schema-per-tenant: Same exposure. The schemas live on one cluster. An accidental DROP SCHEMA CASCADE still requires cluster-level restore.
DB-per-tenant: Each tenant database can be restored independently. This is the only pattern that allows you to restore a single tenant to a point in time without affecting any other tenant. For SaaS products with enterprise SLAs, this capability is often a contractual requirement.
Performance Cliffs Beyond 500 Tenants
Each pattern has a specific inflection point where performance degrades non-linearly.
Shared schema: The cliff is query planner efficiency. With a tenant_id discriminator on every table, the query planner estimates rows across all tenants. With 50 million rows in an orders table across 5,000 tenants, even a composite index on (tenant_id, created_at) produces query plans that degrade as table statistics become less representative of any individual tenant's data distribution. Partition-by-list on tenant_id (PostgreSQL 10+) helps, but partitioning a live shared-schema table is a significant migration operation in itself.
Schema-per-tenant: The cliff is connection pool exhaustion compounded by pg_catalog bloat. PostgreSQL stores schema metadata in pg_catalog. At 10,000 schemas on a single cluster, catalog queries (used by Doctrine's schema manager and by ANALYZE) become measurably slower. Auto-analyze struggles to keep statistics current across thousands of schemas.
DB-per-tenant: The cliff is cluster management overhead. At 1,000 tenant databases on a single PostgreSQL instance, autovacuum, WAL management, and shared buffer allocation become unpredictable. Most teams at this scale move to a sharded cluster model where tenant databases are distributed across multiple PostgreSQL instances routed by a connection proxy.
Choosing a Pattern: The Simplified Decision Tree
Start with shared schema if you have fewer than 100 tenants with no near-term plan to serve regulated industries, your customers are SMBs without DPOs asking about physical isolation, and your primary concern is speed to market. Accept that you will face a migration later if you move upmarket.
Use schema-per-tenant if you are between 50 and 2,000 tenants, you have mid-market customers who ask about data separation in procurement, you need clean right-to-erasure execution, and you are willing to invest in the PgBouncer configuration and the per-tenant migration runner. This is the right default for greenfield European B2B SaaS in 2026.
Use DB-per-tenant if you are selling to enterprises or regulated industries where physical isolation is a contractual requirement, you need per-tenant PITR, or you are building a product where enterprise customers will eventually demand dedicated infrastructure. Size your DevOps investment accordingly, or use a managed service (Neon, PlanetScale, RDS) where per-DB cost is predictable.
GDPR Obligations by Architecture
Under GDPR Article 30, you must maintain records of processing activities. Under Articles 17 and 18, you must respond to erasure and restriction requests. The architecture you choose directly determines how expensive these obligations are to fulfil operationally.
With shared schema, right-to-erasure requires identifying every table with a tenant_id foreign key and executing coordinated deletions or anonymisations. This is scriptable, but the surface area grows with every new table added by any developer. A missed table is a compliance gap.
With schema-per-tenant, erasure is a DROP SCHEMA tenant_7a3f CASCADE — one statement, atomic, complete. The schema boundary also gives you a clear perimeter for Article 30 data mapping: you can generate per-tenant processing records by inspecting the schema.
With DB-per-tenant, the isolation is strongest. You can provide enterprise customers with read-only database credentials for their own audit access — a request that DPOs in FinTech and healthcare are increasingly making as standard due diligence.
Practical Next Steps
If your Symfony application is approaching the inflection points described here — whether that is an enterprise customer asking about data isolation for the first time, a migration taking too long to run in deployment, or connection pool alarms firing at peak load — this is the architecture decision to get right before scale makes it expensive.
Wolf-Tech provides SaaS architecture consulting and technical advisory for exactly these decisions: pre-migration assessment, Doctrine EntityManager factory implementation, PgBouncer configuration, and per-tenant migration runners that fit your deployment pipeline. We have worked through this transition across multiple Symfony products, at tenant counts from 20 to several thousand.
To discuss where your current setup sits and what the migration path looks like, reach out at hello@wolf-tech.io or through wolf-tech.io.
Frequently Asked Questions
Can I start with shared schema and migrate to schema-per-tenant later?
Yes, but complexity is proportional to your tenant count and data volume. Under 100 tenants the migration is a manageable project. At 1,000+ tenants it requires careful planning, parallel-run periods, and robust rollback procedures. The earlier the switch, the lower the cost.
Does Doctrine support schema-per-tenant natively?
Not natively. Multi-tenancy requires the EntityManager factory pattern described above combined with a Symfony service that resolves the current tenant from the request context. There is no first-class Doctrine bundle for multi-tenancy — you build it yourself or use a community package and adapt it to your needs.
What is the performance difference for typical B2B SaaS workloads below 500 tenants?
For standard OLTP patterns dominated by single-tenant queries, the performance difference between the three patterns is negligible below 500 tenants. The patterns diverge at scale and under cross-tenant query patterns. Admin dashboards and platform-wide analytics are fastest in shared schema and most complex in DB-per-tenant, where they require scatter-gather queries across databases.
How does PgBouncer transaction mode interact with Doctrine prepared statements?
PgBouncer transaction mode does not support named prepared statements, which Doctrine uses by default. You must either use PgBouncer session mode (which resolves the search_path issue but reduces pool efficiency) or disable named prepared statements in Doctrine's DBAL configuration. This is typically the first production problem teams hit when moving to schema-per-tenant, and it is worth solving before your first production deployment rather than after.

