Skip to content
5 min read Moustakime KIFIA

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 codeswoma.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.

Keep reading

A few related articles to extend the topic and keep the internal linking strong.

2 min

Building a full stack SaaS as lead tech

A look at the technical choices behind Cercly — from the dev environment to production, across microservices, mobile, and AI-assisted delivery.

  • Architecture
  • Engineering
  • DevOps
  • SaaS
Read the note
3 min

Two Next.js apps on two domains, one shared API

Why we split the customer portal and the operator console into two Next.js apps, and what that separation removed from the codebase.

  • Architecture
  • Multi-tenant
  • Auth
Read the note