Multi-tenant Symfony with PlatformAware and Doctrine filter
How to hold multiple tenants in the same PostgreSQL database with a Platform entity, a PlatformAware trait and a per-request Doctrine filter.
- Multi-tenant
- Architecture
- Engineering
When building a SaaS — an association platform, a B2B tool sold to multiple companies, or a white-label e-commerce for several brands — tenant data isolation comes up early. One database per tenant keeps isolation simple but complicates operations. One schema per tenant complicates migrations. A shared schema with a tenant_id column is often the right trade-off, as long as it is applied systematically.
In Cercly, we host multiple associations on the same PostgreSQL database. Each has its own members, events, and payments. Isolation is applicative: no RLS, no separate schema, just a Doctrine filter activated on every HTTP request. Here is how it is organised.
The platform as the tenant unit
The Platform entity is the entry point for the entire system. It holds a uuid, a code, a status, an optional primaryHostname, and a list of additional hostnames.
Every tenant automatically gets subdomains derived from their code — swoma.platform.cercly.co for the dashboard, swoma.member.cercly.co for the member portal. These URLs require no configuration: they are covered by a wildcard ACM certificate and resolved by matching the subdomain prefix.
The primaryHostname and hostnames fields additionally allow a tenant to bring their own domain (members.myorg.com). That option requires the tenant to set a CNAME pointing to their Cercly subdomain and TLS to be provisioned separately (Cloudflare or manual setup). It is therefore not automated in the provisioning flow, but the resolution infrastructure supports it.
Tenant resolution follows this priority order:
JWT (platform_uuid)
→ exact hostname match (primaryHostname / hostnames)
→ subdomain pattern ({code}.platform.* or {code}.member.*)
→ default platform
In src/Platform/CurrentPlatformResolver.php:
public function resolveCurrentPlatform(?Request $request = null): ?Platform
{
// 1. Already resolved for this request (request-scoped cache)
$contextPlatform = $this->currentPlatformContext->getPlatform();
if ($contextPlatform instanceof Platform
&& $this->entityManager->contains($contextPlatform)) {
return $contextPlatform;
}
$request ??= $this->requestStack->getCurrentRequest();
$platform = $this->resolveFromJwtPayload()
?? $this->resolveFromRequestHost($request)
?? $this->platformRepository->findDefaultPlatform();
if ($platform instanceof Platform) {
$this->currentPlatformContext->setPlatform($platform);
}
return $platform;
}
private function resolveFromRequestHost(?Request $request): ?Platform
{
$host = mb_strtolower(trim((string) $request?->getHost()));
if ($host === '') { return null; }
// Exact domain registered on the tenant (custom domain)
$platform = $this->platformRepository->findOneByHostname($host);
if ($platform instanceof Platform) { return $platform; }
// Subdomain pattern: swoma.platform.cercly.co → code "swoma"
if (preg_match('/^([a-z0-9-]+)\.(platform|member)\./i', $host, $matches)) {
return $this->platformRepository->findOneByCode($matches[1]);
}
return null;
}
Declaring that an entity belongs to a tenant
Every tenant-scoped entity implements PlatformAwareInterface and uses PlatformAwareTrait. The interface declares the contract; the trait provides the column and the accessors:
interface PlatformAwareInterface
{
public function getPlatform(): ?Platform;
public function setPlatform(?Platform $platform): static;
}
trait PlatformAwareTrait
{
#[ORM\ManyToOne(targetEntity: Platform::class)]
#[ORM\JoinColumn(name: 'platform_id', referencedColumnName: 'id', nullable: false)]
private ?Platform $platform = null;
public function getPlatform(): ?Platform { return $this->platform; }
public function setPlatform(?Platform $platform): static
{
$this->platform = $platform;
return $this;
}
}
On the entity side, two lines are all it takes. In src/Entity/Customer/Customer.php:
class Customer extends BaseCustomer implements PlatformAwareInterface
{
use PlatformAwareTrait;
The platform_id column comes from the trait. Same pattern for Event, Expense, LegalConsent, PaymentMethod — anything that holds data belonging to a specific tenant.
The Doctrine filter: the automatic WHERE clause
In config/packages/doctrine.yaml, the filter is declared disabled by default:
doctrine:
orm:
filters:
platform_scope:
class: App\Doctrine\Filter\PlatformScopeFilter
enabled: false
It is enabled on every HTTP request by a subscriber. Once active, it inspects each Doctrine query: if the target entity implements PlatformAwareInterface, it appends WHERE platform_id = :current_platform. Otherwise it leaves the query untouched.
final class PlatformScopeFilter extends SQLFilter
{
public function addFilterConstraint(ClassMetadata $targetEntity, string $targetTableAlias): string
{
if ($targetEntity->getName() === Platform::class) {
return '';
}
if (!$targetEntity->getReflectionClass()->implementsInterface(PlatformAwareInterface::class)) {
return '';
}
try {
$platformId = $this->getParameter('platform_id');
} catch (\InvalidArgumentException) {
return '';
}
return sprintf('%s.platform_id = %s', $targetTableAlias, $platformId);
}
}
The Platform entity itself is explicitly excluded from the filter — otherwise the initial tenant resolution would loop back on itself.
Activating the right filter for the right request
PlatformRequestScopeSubscriber listens to two events. On KernelEvents::REQUEST (priority 120, before authentication), it resolves the tenant from the hostname and configures the filter:
public function onKernelRequest(RequestEvent $event): void
{
if (!$event->isMainRequest()) { return; }
$this->currentPlatformContext->reset();
$filters = $this->entityManager->getFilters();
if ($filters->isEnabled('platform_scope')) {
$filters->disable('platform_scope');
}
$platform = $this->currentPlatformResolver->resolveCurrentPlatform($event->getRequest());
if ($platform === null) { return; }
$filter = $filters->enable('platform_scope');
$filter->setParameter('platform_id', (string) $platform->getId());
}
On Events::JWT_AUTHENTICATED, the subscriber replaces that resolution with the platform_uuid from the token. For ShopUser accounts, it also verifies that the customer belongs to the tenant declared in the JWT — a guard against reusing a valid token on a different domain.
CurrentPlatformContext caches the resolved platform for the duration of the request, avoiding repeated SQL lookups across application layers.
What the Next.js frontends contribute
The member and platform apps run on per-tenant subdomains (swoma.member.cercly.co, swoma.platform.cercly.co). The Next.js middleware reads the session cookie and injects the tenant context into headers before every API call:
if (pathname.startsWith("/api/") && !pathname.startsWith("/api/health")) {
const requestHeaders = new Headers(request.headers);
if (principal) {
requestHeaders.set("x-cercly-auth-mode", "headers");
requestHeaders.set("x-cercly-sub", principal.subject);
requestHeaders.set(
"x-cercly-tenant-access",
`${principal.tenantId}:${principal.role}`
);
}
return NextResponse.next({ request: { headers: requestHeaders } });
}
The operator app does the same with its own roles (x-cercly-operator-roles). These x-cercly-* headers are read by operator-api (NestJS), which builds its principal directly from them. platform-api (Symfony) relies on the request hostname and JWT to resolve the tenant — the two APIs have their own isolation model; they share neither the resolution logic nor the current context storage.
What this pattern makes explicit
The benefit of this approach is not performance — a platform_id column with an index covers the volumes of a standard SaaS without issue. The benefit is legibility: looking at an entity, you immediately know whether it is tenant-scoped. Looking at the subscriber, you know exactly when and how the filter is activated.
The fact that the filter is enabled: false by default in the config is not a detail. It is an explicit decision: activation is always deliberate, always tied to a real resolution of the tenant context. No request can pass through unscoped if the subscriber is in place.
This is not the only way to do multi-tenancy in Symfony. Database-per-tenant or schema-per-tenant isolation may make sense depending on the required confidentiality level or the size of individual tenants. But for a SaaS where tenants share the same infrastructure and operational simplicity matters, this pattern covers the ground well.
The next article will show how a new tenant enters this system: the provisioning workflow, from sign-up to first active access.