Multi-Tenant avec Symfony : switch dynamique de BDD par sous-domaine
Retour d'expérience concret sur l'implémentation d'une architecture multi-tenant dans Symfony. Une base de données par client, zéro librairie tierce, 3 fichiers, 4 étapes.
Quand on parle d'architecture multi-tenant, la première réaction est souvent de chercher un bundle, une librairie, une abstraction qui "gère ça". Erreur. Dans un SaaS Symfony que j'ai développé récemment, on a résolu le problème avec 3 fichiers et 4 étapes. Pas de dépendance tierce, pas de magie noire : juste une exploitation fine des mécanismes internes de Doctrine DBAL.
Voici le mécanisme complet, décortiqué.
Le contexte : une BDD par client
La plateforme est utilisée par plusieurs clients, chacun accessible via son propre sous-domaine : client1.monapp.fr, client2.monapp.fr, etc. Chaque client possède sa propre base de données MySQL, avec un schéma identique mais des données totalement isolées.
L'enjeu : au moment où une requête HTTP arrive, Symfony doit détecter quel client est concerné et basculer automatiquement vers la bonne BDD, avant même que les contrôleurs ne s'exécutent.
Le flux complet tient en 4 étapes :
Requête HTTP → Détection sous-domaine → Résolution config BDD → Switch connexion DBAL → EntityManager prêt
Étape 1, DatabaseSwitchListener
Tout commence à chaque requête HTTP. Un EventListener Symfony branché sur kernel.request avec une priorité de 5 déclenche le switch avant l'exécution des contrôleurs :
// src/EventListener/DatabaseSwitchListener.php
<?php
namespace App\EventListener;
use App\Service\DatabaseConnectionService;
use Symfony\Component\HttpKernel\Event\RequestEvent;
class DatabaseSwitchListener
{
public function __construct(
private DatabaseConnectionService $databaseConnectionService
) {}
public function onKernelRequest(RequestEvent $event): void
{
if (!$event->isMainRequest()) {
return;
}
$this->databaseConnectionService->getEntityManager();
}
}
Enregistré dans services.yaml :
# config/services.yaml
App\EventListener\DatabaseSwitchListener:
tags:
- {
name: kernel.event_listener,
event: kernel.request,
method: onKernelRequest,
priority: 5,
}
C'est tout. Un simple appel à getEntityManager() à chaque requête principale. Pas de middleware complexe, pas de décorateur, un listener minimaliste qui délègue tout le travail aux services.
La priorité 5 est délibérée : elle garantit que le switch se fait après le routing Symfony (priorité plus haute) mais avant les contrôleurs. Pas de risque d'exécuter du code métier sur la mauvaise BDD.
Étape 2, SubdomainConfigService
Ce service a deux responsabilités : identifier le tenant et résoudre sa configuration de BDD.
Extraire le sous-domaine de la requête
public function getSubdomain(): ?string
{
$request = $this->requestStack->getCurrentRequest();
if (null === $request) {
return null;
}
$host = $request->getHost();
$hostParts = explode('.', $host);
return $hostParts[0] ?? null;
}
Simple et efficace : client1.monapp.fr → explode → client1. C'est l'identifiant du tenant.
Un système d'alias gère les cas particuliers (sous-domaine public différent du nom interne) :
private const SUBDOMAIN_ALIASES = [
'alias-public' => 'nom-interne',
];
Résoudre la configuration BDD depuis un YAML
Une fois le sous-domaine identifié, on récupère ses credentials depuis un fichier subdomains.yaml :
public function getDbConfigForSubdomain(string $subdomain): ?array
{
$internalSubdomain = self::SUBDOMAIN_ALIASES[$subdomain] ?? $subdomain;
return $this->subdomains[$internalSubdomain] ?? null;
}
Le tableau $this->subdomains est chargé au démarrage depuis config/subdomains.yaml avec une mise en cache intelligente basée sur le timestamp du fichier :
public function getSubdomainsConfig(): ?array
{
$fileTimestamp = filemtime($this->subdomainsFilePath);
return $this->cache->get(self::CACHE_KEY, function (ItemInterface $item) use ($fileTimestamp) {
$cachedTimestamp = $item->get()['ts'] ?? 0;
if ($cachedTimestamp === $fileTimestamp) {
return $item->get();
}
$config = [
'subdomains' => Yaml::parseFile($this->subdomainsFilePath),
'ts' => $fileTimestamp,
];
array_walk_recursive($config['subdomains'], function (&$value) {
if (preg_match('/%env\((.*?)\)%/', $value, $matches)) {
$value = $_SERVER[$matches[1]] ?? null;
}
});
return $config;
})['subdomains'] ?? null;
}
Point important : les placeholders %env(CLIENT1_DB)% du YAML sont résolus manuellement via $_SERVER, et non via le container Symfony, car ce fichier est parsé en dehors du système de configuration standard. Avantage concret : le fichier peut être monté en volume Docker et modifié à chaud, sans rebuild de l'image.
Le fichier subdomains.yaml associe chaque tenant à ses credentials :
# config/subdomains.yaml
client1:
db: '%env(CLIENT1_DB)%'
user: '%env(CLIENT1_USER)%'
password: '%env(CLIENT1_PASSWORD)%'
client2:
db: '%env(CLIENT2_DB)%'
user: '%env(CLIENT2_USER)%'
password: '%env(CLIENT2_PASSWORD)%'
Étape 3, DatabaseConnectionService
C'est ici que la magie opère. Ce service reconstruit la connexion DBAL à la volée avec les paramètres du tenant :
public function getEntityManager(): EntityManager
{
$subdomain = $this->subdomainConfigService->getSubdomain();
if (null === $subdomain) {
throw new \Exception('Aucun sous-domaine défini pour cette requête.');
}
$dbConfig = $this->subdomainConfigService->getDbConfigForSubdomain($subdomain);
if (null === $dbConfig) {
throw new \Exception('Pas de BDD configurée pour : ' . $subdomain);
}
/** @var Connection $connection */
$connection = $this->doctrine->getConnection('master');
$params = $connection->getParams();
$params['dbname'] = $dbConfig['db'];
$params['user'] = $dbConfig['user'];
$params['password'] = $dbConfig['password'];
if ($connection->isConnected()) {
$connection->close();
}
$connection->__construct(
$params,
$connection->getDriver(),
$connection->getConfiguration(),
);
/** @var EntityManager $manager */
$manager = $this->doctrine->getManager('master');
return $manager;
}
Le trick, $connection->__construct()
La technique centrale est l'appel direct au constructeur de l'objet Connection DBAL sans recréer l'instance. Peu orthodoxe, mais redoutablement efficace :
- On garde la même instance de
Connection, le container Symfony et l'EntityManager conservent leur référence - On remplace ses paramètres internes (
dbname,user,password) par ceux du tenant courant - La connexion est fermée avant la reconstruction pour éviter toute fuite de session
- L'EntityManager
master, qui pointe vers cette connexion, se retrouve automatiquement connecté à la bonne BDD
Résultat : un seul EntityManager et une seule connexion DBAL suffisent. Ils sont réutilisés et re-paramétrés à chaque requête, sans overhead de création d'objet.
Étape 4, la configuration Doctrine
Côté Doctrine, seuls deux éléments sont nécessaires pour que le switch fonctionne : une connexion master par défaut et un EntityManager lié à cette connexion.
# config/packages/doctrine.yaml (extrait multi-tenant)
doctrine:
dbal:
default_connection: master
connections:
master:
url: "%env(DATABASE_MASTER_URL)%"
orm:
default_entity_manager: master
auto_mapping: true
connection: master
mappings:
Master:
type: attribute
dir: '%kernel.project_dir%/src/Entity'
prefix: 'App\Entity'
Toutes les entités sont mappées une seule fois via App\Entity. Le schéma est identique dans toutes les BDD tenants, seules les données diffèrent.
Le
DATABASE_MASTER_URLpeut pointer vers n'importe quelle BDD tenant valide, c'est juste la valeur initiale. LeDatabaseConnectionServicela remplace à chaque requête avec les credentials du tenant détecté.
Le flux complet en un coup d'œil
Requête entrante →
client1.monapp.fr
1. Listener : DatabaseSwitchListener::onKernelRequest() intercepte la requête (priorité 5)
2. Détection : SubdomainConfigService::getSubdomain() extrait "client1" du host
3. Résolution : getDbConfigForSubdomain('client1') lit le cache du YAML et retourne { db: "app_client1", user: "…", password: "…" }
4. Switch : DatabaseConnectionService::getEntityManager() ferme la connexion master, rappelle son constructeur avec les nouveaux paramètres et retourne l'EntityManager
Résultat → L'EntityManager est connecté à
app_client1. Tous les Repository et QueryBuilder tapent dans la bonne BDD.
Ce qu'on retient
Cette approche tourne en production sans incident sur plus d'une centaine de tenants. Quelques points à retenir si vous envisagez quelque chose de similaire :
Les avantages : zéro dépendance tierce, un seul EntityManager à gérer, le fichier subdomains.yaml est modifiable à chaud via Docker, et la mise en cache par timestamp évite de parser le YAML à chaque requête.
Les limites à anticiper : cette approche suppose un schéma de BDD identique entre tous les tenants. Si vos clients ont des structures différentes, il faudra plusieurs EntityManagers et une stratégie de mapping par tenant. La gestion des migrations doit aussi être adaptée : on migre chaque BDD tenant individuellement.
Quand l'utiliser : c'est une solution taillée pour des SaaS avec un fort volume de tenants partageant le même modèle métier, et où la simplicité opérationnelle prime sur la flexibilité du schéma.
Vous travaillez sur une architecture multi-tenant similaire ? Une question sur l'implémentation ? N'hésitez pas à me contacter.