one codebase, many tenants
SaaS applications must run multiple customers on the same codebase without ever letting data leak between tenants. Symfony ships no built-in multi-tenancy solution, but with a clear tenant resolver, the right Doctrine strategy and a consistent request-scoping architecture, you get a system that is secure, maintainable and scalable to any number of tenants.
Table of contents
- 1. Why multi-tenancy in Symfony needs its own architecture
- 2. The three data separation strategies compared
- 3. Tenant resolver: identifying the current tenant
- 4. Anchoring the tenant context in the request lifecycle
- 5. Separate databases with a dynamic Doctrine connection
- 6. Row-level security: tenant_id in every table
- 7. Doctrine filters for automatic tenant restriction
- 8. Migrations and deployment for multi-tenant systems
- 9. Strategies in direct comparison
- 10. Summary
- 11. FAQ
1. Why multi-tenancy in Symfony needs its own architecture
Multi-tenancy in Symfony means that a single code instance serves multiple customers (tenants) at the same time, with complete data isolation. That sounds simple, but the implications run through the entire architecture: every database access must be tenant-specific, sessions and caches must not overlap, queues must be routable per tenant, and errors in one tenant's context must not affect other tenants. Symfony provides no built-in abstraction for this, which makes multi-tenancy an architectural decision, not a feature you bolt on afterwards.
The two most common mistakes in multi-tenancy implementations: first, tenant identification is implemented too late in the stack, in the controller rather than in a kernel event listener. That leads to middleware, caches and services being initialized without a tenant context. Second, Doctrine filters are only activated for some entities, not globally. That is a data-leak risk: a forgotten relation can return data belonging to the wrong tenant. Done correctly, multi-tenancy in Symfony is genuinely elegant: the tenant context is set once, and every subsequent layer consumes it automatically.
2. The three data separation strategies compared
For multi-tenancy systems there are three fundamental data separation strategies that differ significantly in isolation, scalability and operational effort. The first strategy is a separate database per tenant: every tenant gets its own database instance. This offers maximum isolation, a data leak between tenants is physically impossible at the database level. The downside: with 500 tenants you operate 500 database instances. Migrations must be run separately for every database. It makes sense for enterprise SaaS with a small number of paying customers who have strong compliance requirements.
The second strategy is a separate schema per tenant within one database instance (natively supported by PostgreSQL). Every tenant gets its own schema with an identical table structure. A database connection switch is enough to switch tenants. The third strategy is row-level security: all tenants share the same tables, and every row has a tenant_id column. Doctrine filters ensure that every query is automatically restricted to the current tenant. This is the most scalable option for SaaS with many small customers, but it demands utmost care that no query path bypasses the filter.
<?php
declare(strict_types=1);
namespace App\MultiTenancy\Domain;
/**
* Value object representing an identified tenant in the system.
*/
final readonly class Tenant
{
public function __construct(
public readonly string $identifier, // slug used in subdomains and URLs
public readonly int $id, // database surrogate key
public readonly string $databaseName, // used for separate-DB strategy
public readonly string $schemaName, // used for separate-schema strategy
public readonly TenantStatus $status,
) {}
/**
* Check if this tenant is allowed to process requests.
*/
public function isActive(): bool
{
return $this->status === TenantStatus::Active;
}
}
enum TenantStatus: string
{
case Active = 'active';
case Suspended = 'suspended';
case Trial = 'trial';
}
/**
* Contract for resolving the current tenant from the request context.
*/
interface TenantResolverInterface
{
/**
* Resolve the tenant from the current request.
*
* @throws TenantNotFoundException if no tenant can be identified
*/
public function resolve(\Symfony\Component\HttpFoundation\Request $request): Tenant;
}
3. Tenant resolver: identifying the current tenant
The tenant resolver is the most critical component of the multi-tenancy architecture in Symfony, it determines which tenant an incoming request belongs to. The most common identification strategies are subdomain-based (acme.myapp.de), path-based (myapp.de/t/acme/dashboard) and header-based (X-Tenant-ID: acme). Subdomain-based resolvers are the cleanest option for SaaS applications because they impose no URL structure and allow isolation at the DNS level. Header-based resolvers suit API gateways that write the tenant into a header after authentication.
The resolver looks up the tenant object from a central tenant registry, typically a Doctrine entity in a shared database or a cache-backed store. The result is stored in a request attribute so that all subsequent layers can read it without calling the resolver again. The resolver should cache the tenant data (at least per-request, ideally with a short TTL in a Redis cache) so it does not run a database query for tenant resolution on every HTTP request. A failed lookup, because the subdomain does not correspond to any tenant, throws a TenantNotFoundException, which is translated into a 404 response.
4. Anchoring the tenant context in the request lifecycle
The tenant context must be set as early as possible in Symfony's request lifecycle. The right hook is the KernelEvents::REQUEST event with a very high priority (e.g. 100), so the context is available before routing, security and controller resolution. A kernel event listener calls the tenant resolver and stores the tenant object in a TenantContext service, registered as a request-scoped service in the DI container. All other services, the Doctrine connection, the cache-prefix generator, the mailer sender, read the context from this service.
The TenantContext service is a simple stateful value object in the DI container. It holds the current tenant object and throws an exception if no tenant is set and a service tries to access it. This fail-fast behavior matters: it prevents a request without a tenant context from accessing data and, through missing tenant_id filters, seeing data belonging to other tenants. For endpoints that do not need a tenant, health checks, webhook endpoints, the listener sets no context, and all tenant-sensitive services should return a configurable exception or a null tenant in that case.
<?php
declare(strict_types=1);
namespace App\MultiTenancy\Infrastructure\EventListener;
use App\MultiTenancy\Application\TenantContext;
use App\MultiTenancy\Domain\TenantResolverInterface;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
/**
* Resolves the current tenant from the request and stores it in TenantContext.
* Must run before routing, security and controller resolution.
*/
#[AsEventListener(event: KernelEvents::REQUEST, priority: 100)]
final readonly class TenantResolverListener
{
public function __construct(
private TenantResolverInterface $resolver,
private TenantContext $context,
) {}
/**
* Resolve tenant from request and set it as the active tenant context.
*/
public function __invoke(RequestEvent $event): void
{
if (!$event->isMainRequest()) {
return; // Skip sub-requests, tenant is already set from main request
}
$request = $event->getRequest();
// Skip tenant resolution for paths that don't require a tenant context
if ($this->isPublicPath($request->getPathInfo())) {
return;
}
$tenant = $this->resolver->resolve($request);
if (!$tenant->isActive()) {
// Return a 403 for suspended tenants, no stack trace, no details
$event->setResponse(new \Symfony\Component\HttpFoundation\Response(
'Tenant account is suspended.',
\Symfony\Component\HttpFoundation\Response::HTTP_FORBIDDEN,
));
return;
}
$this->context->setTenant($tenant);
// Store on request attributes for easy access in controllers
$request->attributes->set('_tenant', $tenant);
}
private function isPublicPath(string $path): bool
{
return str_starts_with($path, '/_health') || str_starts_with($path, '/_metrics');
}
}
5. Separate databases with a dynamic Doctrine connection
With the separate-database-per-tenant strategy, Doctrine dynamically switches the database connection once the tenant context has been set. The technical mechanism: a Doctrine DBAL event subscriber reacts to connection establishment and selects the right database based on the current tenant context. A more elegant solution is a decorator of the ConnectionInterface that loads the current tenant's credentials from the context on every connection call and builds the physical connection accordingly.
A common problem with dynamic connections in multi-tenancy setups is connection pooling. Standard PHP-FPM has no persistent connection pooling between requests, but with worker processes (RoadRunner, FrankenPHP) connections stay open. Without an explicit connection reset at the start of the request, a worker that last opened a connection for tenant A could still use the old connection for tenant B on the next request. The listener must therefore explicitly mark the Doctrine connection as "uninitialized" or close it after the tenant switch, so a new connection with the correct credentials is established on the next query.
6. Row-level security: tenant_id in every table
Row-level security is the strategy where all tenants share the same database tables, but every row carries a tenant_id column. In PostgreSQL, row-level security can be enforced directly at the database level, on the Symfony side, implementing it via Doctrine filters is the cleanest approach. The Doctrine filter automatically appends a WHERE tenant_id = :current_tenant_id to every affected query. That means no handler, no repository, no service has to filter by tenant manually, the filter makes it transparent for every query Doctrine executes.
The risk with row-level security is the so-called "filter bypass": native SQL queries via EntityManager::getConnection()->executeQuery() bypass the Doctrine filter because it operates at the DQL level. Every developer on the team must know that native queries must explicitly include the tenant_id condition. Code reviews should check native SQL queries for a missing tenant_id restriction. A static analysis rule (a PHPStan custom rule) can flag native query calls and emit a warning. In multi-tenancy systems with high data-protection requirements, this risk is a strong argument for the separate-database strategy.
7. Doctrine filters for automatic tenant restriction
Doctrine SQL filters are an extension point that adds SQL conditions to every query touching a given entity. A tenant filter implements the SQLFilter interface and returns, in the addFilterConstraint method, the SQL fragment that gets appended to the WHERE condition. The filter is registered in the Doctrine configuration and enabled in the kernel event listener right after the tenant context is set. Without activation the filter does not run, which makes it possible to disable it for administrative access that needs to read data across all tenants.
A carefully implemented Doctrine filter for multi-tenancy first checks whether the entity implements HasTenantInterface or carries a specific attribute. Only then is the condition added, shared tables such as country, currency or configuration, which apply to all tenants, remain untouched. The interface pattern is preferable to the attribute pattern because it is statically analyzable and can be checked at compile time to verify that an entity correctly implements tenant filtering.
<?php
declare(strict_types=1);
namespace App\MultiTenancy\Infrastructure\Doctrine;
use App\MultiTenancy\Application\TenantContext;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Query\Filter\SQLFilter;
/**
* Doctrine SQL filter that appends a tenant_id condition to every query
* on entities that implement HasTenantInterface.
*/
final class TenantFilter extends SQLFilter
{
/**
* Inject TenantContext manually, SQLFilter cannot use constructor injection.
*/
private ?TenantContext $context = null;
public function setContext(TenantContext $context): void
{
$this->context = $context;
}
/**
* Add WHERE tenant_id = :tenantId for all tenant-scoped entities.
*/
public function addFilterConstraint(ClassMetadata $targetEntity, string $targetTableAlias): string
{
// Only filter entities that are explicitly marked as tenant-scoped
if (!$targetEntity->reflClass->implementsInterface(HasTenantInterface::class)) {
return '';
}
if ($this->context === null || !$this->context->hasTenant()) {
return '';
}
$tenantId = (int) $this->context->getTenant()->id;
// Return raw SQL condition, Doctrine appends this to every SELECT/UPDATE/DELETE
return sprintf('%s.tenant_id = %d', $targetTableAlias, $tenantId);
}
}
// Interface to mark entities as tenant-scoped, checked by the filter at runtime
interface HasTenantInterface
{
public function getTenantId(): int;
public function setTenantId(int $tenantId): void;
}
8. Migrations and deployment for multi-tenant systems
Database migrations for multi-tenancy systems are considerably more complex than in single-tenant applications. With the separate-database strategy, every migration must run against all tenant databases, usually sequentially or parallelized across worker processes. A migration script iterates over all registered tenants, builds the corresponding database connection for each, and runs the pending Doctrine migrations. Failed migrations for individual tenants must be logged and re-run manually, an automatic rollback strategy across all tenants is practically not feasible.
With the row-level-security strategy, migrations are simpler because there is only one database. But new columns or tables must always be given a sensible default value for existing rows. A new required field in the projects table needs a DEFAULT value that makes sense for all existing tenant data. Zero-downtime deployments in multi-tenancy systems follow the expand/contract pattern: first extend the table (add a new nullable column), then deploy the code that populates the new column, then mark the column as NOT NULL. This three-step process prevents outages while the system is running.
9. Strategies in direct comparison
The choice of multi-tenancy strategy affects every aspect of the system, from database size to the deployment process. A direct comparison helps when deciding for your own project.
| Criterion | Separate database | Separate schema | Row-level (tenant_id) |
|---|---|---|---|
| Data isolation | Maximum | High (PostgreSQL) | Filter-dependent |
| Scalability (tenants) | Limited (infrastructure) | Medium | High (thousands of tenants) |
| Migration effort | High (per DB) | High (per schema) | Low (once) |
| Compliance / GDPR | Ideal | Good | Depends on requirements |
| Development effort | Medium | Medium | High (filter safety everywhere) |
In practice, many multi-tenancy systems combine the strategies: enterprise customers with compliance requirements get a separate database, small customers share a common instance with row-level security. This hybrid solution requires an abstracted data layer that can handle both approaches transparently, more effort, but the most flexible option for SaaS models with different customer segments.
Mironsoft
Symfony SaaS architecture, multi-tenancy and scalable PHP backends
Want to build a multi-tenancy architecture for your Symfony project?
We design and implement secure multi-tenancy architectures with Symfony, from strategy selection through tenant resolver and Doctrine filters to migration pipelines for any number of tenants.
Strategy consulting
Separate DB, schema or row-level security, we recommend the right strategy for your scaling goal
Doctrine integration
Tenant filters, dynamic connections and a secure migration strategy for growing tenant counts
Security audit
Reviewing existing multi-tenancy implementations for data-leak risks and filter bypasses
10. Summary
Multi-tenancy in Symfony is a pervasive architectural decision that spans the database strategy, the tenant resolver, and the migration pipelines. The choice between a separate database, a separate schema, and row-level security determines isolation, scalability and operational effort for the entire lifetime of the project. A tenant context set early via KernelEvents::REQUEST with a high priority ensures that every layer of the system operates tenant-specifically. Doctrine filters for row-level security automate data isolation, but they require consistent code reviews for native queries.
The biggest danger in multi-tenancy implementations is bolting it on afterwards: when an application is first built single-tenant and later converted to multi-tenancy, the data-leak risks are considerable. The right approach is to anchor multi-tenancy as an architectural principle from day one, with clear interfaces, testable resolvers and a global Doctrine filter strategy that knows no exceptions.
Symfony Multi-Tenancy, the essentials at a glance
Early context
KernelEvents::REQUEST with priority 100, set the tenant context before routing, security and controller resolution. Fail-fast when context is missing.
Doctrine filter
SQLFilter automatically adds WHERE tenant_id, only for entities with HasTenantInterface. Native queries must be secured manually.
Choosing a strategy
Separate DB for compliance requirements. Row-level for thousands of small customers. Hybrid for mixed customer segments.
Migrations
Expand/contract pattern for zero downtime. With separate-DB, a migration script for all tenant databases with an error log.