Introduction
Le multi-tenant n’est pas qu’un choix d’architecture : c’est un levier business pour accélérer l’onboarding, réduire les coûts et améliorer la sécurité des données. Avec Symfony, vous disposez d’outils solides pour implémenter l’isolation des locataires, la résolution via sous-domaine ou en-tête HTTP, et des mécanismes de performance, sécurité et facturation pour construire un SaaS multi-tenant robuste.
Qu’est-ce qu’une application multi-tenant ?
Une application multi-tenant sert plusieurs clients (locataires) à partir d’un même déploiement d’infrastructure et de code. Le défi principal : garantir une isolation efficace des données et des ressources, tout en conservant des opérations simples (provisionnement, migrations, monitoring, facturation).
Choisir la bonne stratégie d’isolation des données
Trois modèles dominants existent. Aucun n’est universel : choisissez selon vos exigences de sécurité, complexité opérationnelle et coût.
1) Base de données par locataire
- Principe : chaque locataire a sa propre base (p. ex. app_tenantA, app_tenantB).
- Avantages :
- Isolation forte (sécurité, conformité, restauration).
- Migrations ciblées et rollbacks indépendants.
- Inconvénients :
- Coût opérationnel plus élevé (provisionnement, connexions, supervision).
- Difficultés pour les rapports transverses.
- Quand l’utiliser : clients Enterprise, exigences réglementaires strictes, tailles de données importantes.
2) Schéma par locataire
- Principe : une seule base, un schéma par locataire (tenant_a.user, tenant_b.invoice).
- Avantages :
- Bon compromis isolation/coût.
- Restauration et migration par schéma.
- Inconvénients :
- Gestion des schémas et migrations plus complexes que le modèle logique.
- Risque de prolifération du nombre de schémas.
- Quand l’utiliser : middle-market, besoin d’isolation raisonnable avec coûts maîtrisés.
3) Filtrage logique (colonne tenant_id)
- Principe : une seule base et un seul schéma ; les tables ont une colonne tenant_id et les requêtes filtrent dessus.
- Avantages :
- Simplicité d’outillage (Doctrine, migrations, requêtes).
- Très économique.
- Inconvénients :
- Risque de fuite de données en cas d’oubli du filtre.
- Contrainte de performance : nécessité d’indexer tenant_id partout.
- Quand l’utiliser : MVP/PMF, nombreux petits locataires, besoin de simplicité.
Bon réflexe : commencez par le filtrage logique pour valider le marché, migrez vers schéma/base par locataire quand la taille et les exigences évoluent.
Résolution du locataire : sous-domaine, en-tête ou autre
Plusieurs stratégies de résolution coexistantes sont possibles :
- Sous-domaine : https://acme.votreapp.com
- En-tête HTTP (API) : X-Tenant-ID: acme
- Chemin : https://votreapp.com/t/acme (souvent pour back-office)
- À la connexion : association utilisateur → locataire (moins explicite côté cache/CDN)
Exemple de résolveur Symfony combinant sous-domaine et en-tête.
<?php
// src/Tenancy/TenantResolver.php
namespace App\Tenancy;
use Symfony\Component\HttpFoundation\Request;
final class TenantResolver
{
public function resolve(Request $request): ?string
{
// 1) Priorité à l’en-tête explicite
$header = $request->headers->get('X-Tenant-ID');
if ($header && preg_match('/^[a-z0-9-]{1,64}$/', $header)) {
return strtolower($header);
}
// 2) Sous-domaine
$host = $request->getHost(); // acme.example.com
$parts = explode('.', $host);
if (count($parts) > 2) {
$sub = strtolower($parts[0]);
if (preg_match('/^[a-z0-9-]{1,64}$/', $sub) && $sub !== 'www') {
return $sub;
}
}
// 3) Paramètre de développement (facultatif)
$q = $request->query->get('tenant');
if ($q && preg_match('/^[a-z0-9-]{1,64}$/', $q)) {
return strtolower($q);
}
return null; // locataire public/anonyme
}
}
Ajoutez un subscriber pour attacher le locataire au cycle de requête.
<?php
// src/Tenancy/TenantRequestSubscriber.php
namespace App\Tenancy;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
final class TenantRequestSubscriber implements EventSubscriberInterface
{
public function __construct(
private TenantResolver $resolver,
private TenantContext $context,
private EntityManagerInterface $em,
) {}
public static function getSubscribedEvents(): array
{
return [KernelEvents::REQUEST => ['onKernelRequest', 100]];
}
public function onKernelRequest(RequestEvent $event): void
{
if (!$event->isMainRequest()) {
return;
}
$tenant = $this->resolver->resolve($event->getRequest());
$this->context->setTenant($tenant);
// Ajoutez ici l’intégration Doctrine (filtre, schéma ou base)
// selon la stratégie choisie (voir sections suivantes).
}
}
Un simple contexte « request-scoped » pour exposer le locataire.
<?php
// src/Tenancy/TenantContext.php
namespace App\Tenancy;
final class TenantContext
{
private ?string $tenant = null;
public function setTenant(?string $tenant): void { $this->tenant = $tenant; }
public function getTenant(): ?string { return $this->tenant; }
}
Côté cache HTTP, pensez à varier les réponses selon le locataire :
// Ajouter dans un listener de réponse
$response->headers->set('Vary', 'Host, X-Tenant-ID');
Intégrer le locataire à Doctrine
Option A — Filtrage logique via Doctrine Filter
- Déclarez une colonne tenant_id sur les entités multi-tenant.
<?php
// src/Entity/Project.php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ORM\Table(name: 'project')]
#[ORM\UniqueConstraint(columns: ['tenant_id', 'slug'])]
class Project
{
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 64)]
private string $tenant_id;
#[ORM\Column(length: 100)]
private string $slug;
// ...
}
- Créez un filtre Doctrine.
<?php
// src/Doctrine/TenantFilter.php
namespace App\Doctrine;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Query\Filter\SQLFilter;
final class TenantFilter extends SQLFilter
{
public function addFilterConstraint(ClassMetadata $targetEntity, $targetTableAlias): string
{
// Appliquez le filtre uniquement si la colonne existe
if (!$targetEntity->hasField('tenant_id')) {
return '';
}
$tenant = $this->getParameter('tenant_id'); // déjà quoté par Doctrine
return sprintf('%s.tenant_id = %s', $targetTableAlias, $tenant);
}
}
- Configurez et activez le filtre.
## config/packages/doctrine.yaml
doctrine:
orm:
filters:
tenant:
class: App\Doctrine\TenantFilter
enabled: true
// Dans TenantRequestSubscriber::onKernelRequest()
$filter = $this->em->getFilters()->enable('tenant');
$filter->setParameter('tenant_id', $this->context->getTenant() ?? 'public');
- Performance et sécurité :
- Indexez systématiquement tenant_id et les colonnes filtrées.
- Déclarez les uniques en (tenant_id, champ) pour éviter les collisions cross-tenant.
- Écrivez des tests pour vérifier que chaque requête multi-tenant inclut la colonne (les entités « globales » n’ont pas tenant_id).
Exemple d’index SQL:
CREATE INDEX idx_project_tenant ON project (tenant_id);
CREATE UNIQUE INDEX uniq_project_tenant_slug ON project (tenant_id, slug);
Option B — Schéma dédié (PostgreSQL)
Après résolution du locataire, sélectionnez dynamiquement le schéma.
// Dans TenantRequestSubscriber::onKernelRequest()
$tenant = $this->context->getTenant() ?? 'public';
$schema = 'tenant_' . preg_replace('/[^a-z0-9_]/', '_', $tenant);
$this->em->getConnection()->executeQuery('SET search_path TO '.$schema.', public');
- Provisionnement : créez le schéma tenant_xxx via un script d’onboarding.
- Migrations : exécutez-les par schéma (boucle sur la liste des schémas).
- Limitez la prolifération : archivez/désactivez les schémas inactifs.
Option C — Base par locataire
Au lieu de changer de schéma, changez de base de données.
<?php
// src/Tenancy/TenantConnectionFactory.php
namespace App\Tenancy;
use Doctrine\DBAL\DriverManager;
use Doctrine\DBAL\Connection;
final class TenantConnectionFactory
{
public function __construct(private array $baseParams) {}
public function forTenant(string $tenant): Connection
{
$params = $this->baseParams;
$params['dbname'] = 'app_' . $tenant;
return DriverManager::getConnection($params);
}
}
Vous pouvez ensuite créer un EntityManager par locataire. Cette approche requiert une intégration soignée du cycle de vie (fermeture des connexions, pools, migrations par base).
Provisioning et migrations
- Provisioning :
- Filtrage logique : créez les données « racine » (organisation, admin) avec tenant_id.
- Schéma/base : créez le schéma/la base, appliquez les migrations, seed initial.
- Migrations : exécutez-les pour chaque locataire. Orquestrez cela en tâche asynchrone pour éviter les timeouts web.
- Rollback : base par locataire = restauration simple ; sinon, stratégie de migration down + sauvegardes.
Performance et scalabilité
- Requêtes :
- Index multi-colonnes (tenant_id, champs filtrés).
- Partitionnement par tenant ou par période si les volumes explosent.
- Préchargez les relations fréquentes et utilisez des DTO pour éviter la sérialisation massive.
- Caches :
- Clé de cache préfixée par le locataire.
- HTTP cache/CDN avec Vary: Host, X-Tenant-ID.
- Connexions :
- Surveiller le nombre de connexions (schéma/base par locataire).
- Pooling côté DB et limites par locataire.
- Observabilité :
- Taggez toutes les métriques et traces par locataire (logs structurés).
- Montée en charge :
- Sharding vertical : migrez les plus gros locataires vers une base dédiée.
- Jobs batch par locataire pour lisser la charge.
Astuce : si vous migrez ultérieurement de « filtrage logique » vers « schéma/base », introduisez d’abord une abstraction TenantRepository/Storage pour minimiser l’impact code.
Sécurité et conformité
- Cloisonnement :
- Les contrôleurs et services ne doivent jamais accepter de tenant_id « fourni par l’utilisateur ». Ils doivent l’obtenir du contexte résolu.
- Tests de non-régression : un locataire ne peut jamais accéder à une ressource d’un autre locataire.
- Données sensibles :
- Chiffrement applicatif par locataire pour les champs critiques (clé KMS dérivée par locataire).
- Journaux et erreurs :
- Incluez l’identifiant du locataire dans les logs structurés sans y injecter de PII.
- Autorisation :
- Combinez l’isolation des données avec des Voters/attributs d’autorisation au sein du locataire.
Facturation et mesure d’usage
Définissez tôt un modèle d’usage pour la tarification.
- Événements d’usage :
- Exemple : user_created, document_stored, request_mb, seats_active.
- Stockage minimal :
CREATE TABLE usage_events (
id bigserial primary key,
tenant_id varchar(64) not null,
event varchar(64) not null,
weight numeric(12,2) not null default 1,
occurred_at timestamptz not null default now()
);
CREATE INDEX idx_usage_tenant_time ON usage_events (tenant_id, occurred_at);
- Émission d’événements depuis votre code :
<?php
// Exemple léger : incrément à la création d'un utilisateur
use Doctrine\ORM\Event\LifecycleEventArgs;
final class UsageListener
{
public function postPersist(object $entity, LifecycleEventArgs $args): void
{
if ($entity instanceof \App\Entity\User) {
$conn = $args->getEntityManager()->getConnection();
$tenant = $entity->getTenantId();
$conn->insert('usage_events', [
'tenant_id' => $tenant,
'event' => 'user_created',
'weight' => 1,
]);
}
}
}
- Agrégation : exécutez un job périodique qui agrège usage_events → usage_daily par locataire/événement.
- Facturation : calculez le montant par plan + usage. Exposez un tableau de bord d’usage pour éviter les surprises.
Tests, qualité et monitoring
- Tests d’intégration multi-tenant :
- Seed de deux locataires, vérifiez isolation (A ne voit pas B).
- Tests d’unicité par locataire (email unique par tenant).
- Tests de non-régression :
- Scannez les repos pour détecter les requêtes sans tenant_id.
- Monitoring :
- SLO par locataire (latence p95, erreurs).
- Alertes ciblées si un locataire dégrade les performances.
Checklist de décision rapide
- Données sensibles, contrats Enterprise ? → base par locataire.
- Besoin d’un bon compromis coût/opérations ? → schéma par locataire.
- MVP ou nombreux petits locataires ? → filtrage logique.
- APIs publiques ? → résolvez via en-tête X-Tenant-ID et variez le cache.
- Front-office par espace client ? → sous-domaine par locataire.
Conclusion
Construire un SaaS multi-tenant avec Symfony exige des choix clairs d’isolation et une résolution fiable du locataire. Commencez simple (filtrage logique), sécurisez vos filtres et index, mesurez l’usage pour la facturation, et anticipez la migration vers schéma/base par locataire si nécessaire. Besoin d’un audit d’architecture ou d’un accompagnement sur votre migration multi-tenant ? Contactez-nous : nous pouvons cadrer, prototyper et fiabiliser votre plateforme rapidement.