Aller au contenu
6 min de lecture Moustakime KIFIA

Symfony en multi-tenant avec PlatformAware et filtre Doctrine

Comment tenir plusieurs tenants dans la même base PostgreSQL avec une entité Platform, un trait PlatformAware et un filtre Doctrine activé par requête.

  • Multi-tenant
  • Architecture
  • Engineering

Quand on construit un SaaS — une plateforme associative, un outil B2B vendu à plusieurs entreprises, ou un e-commerce en marque blanche pour plusieurs enseignes — la question de l’isolation des données revient vite. Une base par tenant simplifie l’isolation mais complique l’exploitation. Un schéma par tenant complique les migrations. Le schéma partagé avec une colonne tenant_id est souvent le bon compromis, à condition de l’appliquer de façon systématique.

Dans Cercly, on héberge plusieurs associations sur la même base PostgreSQL. Chacune a ses membres, ses événements, ses paiements. L’isolation est applicative : pas de RLS, pas de schéma séparé, mais un filtre Doctrine activé à chaque requête HTTP. Voilà comment ça s’organise.

La plateforme comme unité de tenant

L’entité Platform est le point d’entrée de tout le système. Elle porte un uuid, un code, un status, un primaryHostname optionnel et une liste de domaines supplémentaires.

Chaque tenant dispose automatiquement de sous-domaines dérivés de son codeswoma.platform.cercly.co pour le dashboard, swoma.member.cercly.co pour le portail membre. Ces URL ne nécessitent aucune configuration : elles sont couvertes par un certificat wildcard ACM et résolues par pattern sur le sous-domaine.

Les champs primaryHostname et hostnames permettent en plus de rattacher un domaine propre au tenant (membres.monasso.fr). Cette option requiert que le tenant configure un CNAME vers son sous-domaine Cercly et que le TLS soit provisionné séparément (Cloudflare ou configuration manuelle). Elle n’est donc pas automatisée dans le provisioning, mais l’infrastructure de résolution la supporte.

La résolution du tenant courant suit cet ordre de priorité :

JWT (platform_uuid)
  → hostname exact (primaryHostname / hostnames)
    → pattern sous-domaine ({code}.platform.* ou {code}.member.*)
      → plateforme par défaut

Dans src/Platform/CurrentPlatformResolver.php :

public function resolveCurrentPlatform(?Request $request = null): ?Platform
{
    // 1. Contexte déjà résolu (cache request-scoped)
    $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; }

    // Domaine exact configuré sur le tenant (custom domain)
    $platform = $this->platformRepository->findOneByHostname($host);
    if ($platform instanceof Platform) { return $platform; }

    // Pattern sous-domaine : 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;
}

Déclarer qu’une entité appartient à un tenant

Toute entité tenant-scoped implémente PlatformAwareInterface et embarque PlatformAwareTrait. L’interface déclare le contrat, le trait apporte la colonne et les accesseurs :

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;
    }
}

Côté entité, deux lignes suffisent. Dans src/Entity/Customer/Customer.php :

class Customer extends BaseCustomer implements PlatformAwareInterface
{
    use PlatformAwareTrait;

La colonne platform_id est ajoutée par le trait. Même chose pour Event, Expense, LegalConsent, PaymentMethod — tout ce qui porte des données propres à un tenant.

Le filtre Doctrine : la clause WHERE automatique

Dans config/packages/doctrine.yaml, le filtre est déclaré désactivé par défaut :

doctrine:
  orm:
    filters:
      platform_scope:
        class: App\Doctrine\Filter\PlatformScopeFilter
        enabled: false

Il est activé à chaque requête HTTP par un subscriber. Une fois actif, il inspecte chaque requête Doctrine : si l’entité cible implémente PlatformAwareInterface, il ajoute WHERE platform_id = :current_platform. Sinon, il ne touche rien.

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);
    }
}

L’entité Platform elle-même est explicitement exclue du filtre — sans quoi la résolution initiale du tenant tournerait en boucle.

Activer le bon filtre sur la bonne requête

PlatformRequestScopeSubscriber écoute deux événements. Sur KernelEvents::REQUEST (priorité 120, avant l’authentification), il résout le tenant depuis le hostname et configure le filtre :

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());
}

Sur Events::JWT_AUTHENTICATED, le subscriber remplace cette résolution par le platform_uuid tiré du token. Pour les ShopUser, il vérifie aussi que le customer appartient bien au tenant déclaré dans le JWT — une protection contre la réutilisation d’un token valide sur un autre domaine.

CurrentPlatformContext met le résultat en cache pour la durée de la requête, ce qui évite de répéter la résolution SQL à chaque couche de l’application.

Ce que font les frontaux Next.js

Les apps member et platform fonctionnent sur des sous-domaines par tenant (swoma.member.cercly.co, swoma.platform.cercly.co). Le middleware Next.js lit le cookie de session et injecte le contexte tenant dans les headers avant chaque appel API :

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 } });
}

L’app operator fait la même chose avec ses propres rôles (x-cercly-operator-roles). Ces headers x-cercly-* sont consommés par operator-api (NestJS) qui construit son principal directement depuis les headers. platform-api (Symfony), lui, s’appuie sur le hostname de la requête et le JWT pour résoudre le tenant — les deux APIs ont leur propre modèle d’isolation, elles ne partagent ni la résolution ni le stockage du contexte courant.

Ce que ce pattern rend explicite

L’avantage de cette approche n’est pas la performance — une colonne platform_id avec un index couvre largement les volumes d’un SaaS standard. L’avantage, c’est la lisibilité : en regardant une entité, on sait immédiatement si elle est tenant-scoped. En regardant le subscriber, on sait exactement quand et comment le filtre est activé.

Le fait que le filtre soit enabled: false par défaut dans la config n’est pas un détail. C’est une décision explicite : l’activation est toujours volontaire, toujours liée à une résolution réelle du contexte tenant. Aucune requête ne peut traverser silencieusement sans scope si le subscriber est en place.

Ce n’est pas la seule façon de faire du multi-tenant dans Symfony. Une isolation par base de données ou par schéma peut avoir du sens selon le niveau de confidentialité requis ou la taille des tenants. Mais pour un SaaS où les tenants partagent la même infrastructure et où l’opérationnel doit rester simple, ce pattern couvre bien le terrain.

Le prochain article montrera comment un nouveau tenant entre dans ce système : le workflow de provisioning, de l’inscription au premier accès actif.

Continuer la lecture

Quelques articles relies pour renforcer le maillage interne et prolonger les sujets techniques voisins.

2 min

Construire un SaaS full stack en lead tech

Retour sur les choix techniques de Cercly — de l'environnement de dev à la prod, en passant par l'archi micro-services, le mobile et l'accélération par l'IA.

  • Architecture
  • Engineering
  • DevOps
  • SaaS
Lire la note