Chapitre 15.9 — Multi-tenant
Partie 15 : Expert — Chapitre 9/9 Version de référence : Symfony 7.4 / Doctrine.
⏱️ TL;DR
- Multi-tenant : un code, plusieurs clients (tenants) isolés — le modèle d’un SaaS (BookLab vendu à plusieurs organisations de prestataires).
- Trois stratégies d’isolation, du plus simple au plus isolé : base + schéma partagés (colonne
tenant_id), base partagée + schémas séparés, base par tenant.- Résolution du tenant : depuis le sous-domaine (
acme.booklab.app), un en-tête, ou un claim JWT.- Avec la stratégie partagée : un
TenantContext+ un filtre Doctrine (SQLFilter) filtrent automatiquement toutes les requêtes partenant_id.- La sécurité est vitale : une fuite de données entre tenants est catastrophique. L’isolation doit être automatique et infaillible.
- Pour un dev Next.js : c’est l’architecture SaaS multi-tenant classique (sous-domaine par client, isolation des données), côté backend.
🎯 Objectifs
- Comprendre les stratégies d’isolation multi-tenant.
- Résoudre le tenant depuis la requête.
- Filtrer automatiquement les données par tenant.
- Garantir l’isolation (sécurité).
1. Le besoin : un SaaS pour plusieurs clients
BookLab vendu en SaaS : chaque organisation cliente (un studio, un cabinet, une salle) est un tenant avec ses prestataires, services, réservations — isolés des autres. On veut une seule application (un déploiement, un code) qui sert tous les tenants, chacun ne voyant que ses données.
2. Les trois stratégies d’isolation
| Stratégie | Isolation | Complexité | Pour qui |
|---|---|---|---|
Base + schéma partagés (colonne tenant_id) | logique (par filtre) | faible | démarrage, beaucoup de petits tenants |
| Base partagée, schéma par tenant | moyenne | moyenne | isolation renforcée |
| Base par tenant | forte (physique) | élevée | gros clients, exigences réglementaires |
- Partagé : toutes les données dans les mêmes tables, avec une colonne
tenant_id. Simple, économique, mais l’isolation repose sur un filtre rigoureux (§4). - Schéma/base séparés : isolation plus forte (physique), mais migrations et connexions plus complexes (il faut migrer N schémas/bases).
Pour démarrer un SaaS, la stratégie partagée (tenant_id) est la plus pragmatique. On peut migrer les gros clients vers une base dédiée plus tard.
3. Résoudre le tenant
À chaque requête, on identifie quel tenant. Sources courantes :
- Sous-domaine :
acme.booklab.app→ tenant « acme » (le plus courant en SaaS). - En-tête :
X-Tenant: acme(pour une API). - Claim JWT : le token contient le tenant de l’utilisateur (API, Partie 10.4).
Un value resolver (Partie 4.4) ou un listener sur kernel.request résout le tenant tôt et le place dans un service de contexte :
final class TenantContext
{
private ?Tenant $tenant = null;
public function setTenant(Tenant $t): void { $this->tenant = $t; }
public function getTenant(): Tenant
{
return $this->tenant ?? throw new \RuntimeException('Aucun tenant résolu.');
}
}⚠️ Piège worker mode :
TenantContextporte un état par requête — exactement le piège du chapitre 15.3 ! En worker mode (FrankenPHP), ce service doit être réinitialisé entre les requêtes (implémenterResetInterface), sinon la requête d’un tenant verrait les données d’un autre. C’est le croisement de deux dangers (état + multi-tenant) : à traiter avec le plus grand soin.
4. Filtrer automatiquement avec un SQLFilter Doctrine
Avec la stratégie partagée, le risque est d’oublier le WHERE tenant_id = ? sur une requête → fuite de données entre tenants. La parade : un filtre Doctrine (SQLFilter) qui ajoute automatiquement la condition à toutes les requêtes sur les entités concernées.
final class TenantFilter extends SQLFilter
{
public function addFilterConstraint(ClassMetadata $targetEntity, string $alias): string
{
if (!$targetEntity->hasField('tenantId')) {
return '';
}
return sprintf('%s.tenant_id = %s', $alias, $this->getParameter('tenant_id'));
}
}Activé et paramétré (dans le listener de résolution du tenant), ce filtre injecte tenant_id = X dans chaque SELECT — même si vous oubliez le WHERE. On automatise l’isolation au lieu de compter sur la vigilance à chaque requête.
5. La sécurité : l’isolation infaillible
⚠️ Piège critique : une fuite inter-tenant (un client voit les données d’un autre) est l’un des pires incidents pour un SaaS (perte de confiance, RGPD, réputation). L’isolation ne doit jamais reposer sur « on n’oublie pas le
WHERE» — trop fragile. Elle doit être systématique : filtre Doctrine automatique, et/ou séparation physique (base par tenant). Testez l’isolation (un test qui vérifie qu’un tenant ne peut pas accéder aux données d’un autre — Partie 14). Vérifiez aussi les caches (clés préfixées par tenant !), les uploads (chemins par tenant), les logs et les files Messenger (un message doit porter son tenant).
Points d’attention transversaux :
- Cache : préfixer les clés par tenant (
tenant_acme_stats) — sinon un tenant sert le cache d’un autre. - Fichiers : stockage par tenant (dossier/prefix S3 par tenant).
- Messenger : un message async doit transporter son
tenant_id(le worker doit re-positionner le contexte). - Migrations : en base partagée, une migration suffit ; en base/schéma par tenant, il faut migrer chaque tenant.
6. Appliqué à BookLab
BookLab en SaaS : chaque organisation = un tenant, résolu par sous-domaine. Entités marquées d’un tenant_id, filtre Doctrine automatique, caches et fichiers préfixés. Un nouveau client = créer un tenant (et son sous-domaine), sans redéployer. C’est le modèle qui permet de vendre le même produit à N clients — le cœur d’un SaaS rentable (Partie 17).
💡 Pour un dev Next.js : le multi-tenant SaaS (sous-domaine par client, isolation des données) est un pattern que vous croisez côté front (routing par sous-domaine, thème par tenant). Côté Symfony, l’isolation données est le point critique — et le filtre Doctrine automatique est l’outil clé. Sur une architecture BookLab (API Symfony multi-tenant + front Next.js par sous-domaine), vous maîtrisez tout le SaaS : c’est exactement le genre de produit qu’on construit et vend (Partie 17).
✏️ Exercices
Exercice 1. Quelle stratégie d’isolation choisir pour démarrer un SaaS avec beaucoup de petits tenants, et pourquoi ?
✅ Solution
La stratégie base + schéma partagés avec une colonne tenant_id : la plus simple et économique (une seule base, une seule migration, un seul schéma), adaptée à de nombreux petits tenants. L’isolation repose sur un filtre Doctrine automatique (SQLFilter). On peut, plus tard, migrer les gros clients vers une base dédiée si besoin (isolation renforcée). On évite ainsi la complexité prématurée d’une base par tenant dès le départ.
Exercice 2. Pourquoi ne pas se reposer sur « on ajoute WHERE tenant_id = ? à chaque requête » pour isoler les tenants ?
✅ Solution
Parce que c’est fragile : il suffit d’oublier le WHERE sur une requête (nouvelle fonctionnalité, refactor, requête custom) pour provoquer une fuite inter-tenant — un client voit les données d’un autre, incident critique (confiance, RGPD). L’isolation doit être systématique et automatique : un filtre Doctrine (SQLFilter) qui injecte la condition sur toutes les requêtes, et/ou une séparation physique. On ne fait jamais dépendre l’isolation de la vigilance humaine à chaque requête.
Exercice 3. En multi-tenant + worker mode (FrankenPHP), quel danger spécifique guette le TenantContext, et comment l’éviter ?
✅ Solution
Le TenantContext porte un état par requête ; en worker mode, le service survit entre requêtes (chapitre 15.3). S’il n’est pas réinitialisé, la requête du tenant B pourrait hériter du tenant A → fuite inter-tenant (le pire cas). Solution : faire implémenter ResetInterface au TenantContext (Symfony le réinitialise entre les requêtes en worker mode) et le repositionner à chaque requête via le listener de résolution. C’est le croisement de deux pièges (état résiduel + isolation tenant) — à traiter avec un soin extrême et à tester.
🧠 Quiz de révision
1. Qu’est-ce que le multi-tenant ?
Un code/déploiement servant plusieurs clients (tenants) isolés — le modèle d’un SaaS.
2. Citez les trois stratégies d’isolation.
Base + schéma partagés (tenant_id), base partagée + schéma par tenant, base par tenant (isolation croissante, complexité croissante).
3. Comment résout-on le tenant d’une requête ?
Depuis le sous-domaine, un en-tête, ou un claim JWT — placé tôt dans un service de contexte.
4. Comment automatise-t-on le filtrage par tenant ?
Avec un SQLFilter Doctrine qui ajoute tenant_id = X à toutes les requêtes des entités concernées — pas de WHERE oublié.
5. Quel est l’enjeu de sécurité majeur du multi-tenant ?
L’isolation infaillible : jamais de fuite inter-tenant. L’isolation doit être systématique (filtre auto, tests), y compris pour le cache, les fichiers, les logs et Messenger.
Fin de la Partie 15 — et de toutes les parties « cours ». BookLab est complet : de la découverte à la production experte (perf, déploiement, observabilité, durcissement, architecture, packaging, multi-tenant). Vous savez construire, tester, sécuriser, déployer et faire évoluer une application Symfony professionnelle, avec son front Next.js.
Prochaine étape : les parties pratique & carrière. Partie 16 — Annexes — glossaire, cheatsheets, migration Symfony 5→7, ressources.