Une base de données par tenant sur Laravel 12 : comment Zenpresa isole chaque entreprise et livre ses modules en packages Composer
La première décision d’architecture sur Zenpresa a été celle que nous avons rejetée. La manière évidente de construire un SaaS multi-tenant sur Laravel, c’est une colonne tenant_id sur chaque table, un global scope qui ajoute WHERE tenant_id = ? à chaque requête, et une prière pour que personne n’écrive jamais une requête qui contourne le scope. La plupart des plateformes sont construites ainsi. La plupart d’entre elles ont, à un moment ou un autre, montré à un client les données d’un autre client.
Nous avons choisi de donner à chaque entreprise sa propre base de données. Ce billet explique pourquoi, ce que cela coûte, comment le système de modules s’articule par-dessus, et la classe de bugs qui survit même quand l’isolation est bien faite.
Pourquoi une base de données par tenant
Trois raisons, dans l’ordre de fréquence où elles reviennent.
Le rayon d’impact. Un bug dans un système à schéma partagé est par défaut un bug inter-tenants. Un scope oublié, une requête brute, une jointure qui tire le mauvais côté, un outil d’administration avec un joker : chacun de ces cas fait fuiter des données entre clients. Dans un système à base par tenant, ces mêmes bugs renvoient les mauvaises lignes au sein d’une seule entreprise. Grave, mais récupérable, et jamais une notification de violation de données.
Sauvegarde et restauration par client. Quand une entreprise appelle et dit « nous avons supprimé un an de factures par accident », la réponse dans un schéma partagé est une restauration chirurgicale délicate depuis un snapshot d’une base qui contient aussi tous les autres clients. Avec une base par entreprise, la réponse est de restaurer une base. L’export au départ est la même histoire : un dump d’une base, pas un extrait filtré accompagné d’une revue juridique.
Plus de classe de bugs « scope oublié ». C’est celle qui nous tient le plus à cœur. Dans un schéma partagé, l’isolation des tenants est une propriété que vous devez rétablir dans chaque requête pour toute la vie de la base de code. Dans un système à base par tenant, c’est une propriété de la connexion. Une fois la requête liée à un tenant, chaque modèle Eloquent, chaque instruction brute et chaque job en file d’attente qui hérite de ce contexte est isolé par construction. Les nouveaux ingénieurs ne peuvent pas se tromper, parce qu’il n’y a rien à faire correctement.
Nous utilisons le package stancl/tenancy sur Laravel 12 avec Postgres, et il fait le gros du travail : créer une base à l’inscription, basculer la connexion à chaque requête et exécuter les migrations sur tous les tenants.
Base centrale vs bases tenant
Il existe deux types de base de données dans le système.
La base centrale contient ce qui existe au-dessus de toute entreprise particulière : le registre des tenants, les domaines qui pointent vers chaque tenant, l’état des abonnements et de la facturation pour la plateforme elle-même, les utilisateurs et rôles au niveau plateforme, le catalogue des modules et la piste d’audit de qui a provisionné quoi. Si une table décrit la relation entre une entreprise et la plateforme, elle vit ici.
Chaque base tenant contient tout ce que l’entreprise possède : clients, rendez-vous, prestations, personnel, factures, paiements, pages du site, identité visuelle, modèles d’emails, historique des conversations de l’assistant, etc. Si une table serait exportée au départ de l’entreprise, elle vit ici.
Le jeu de migrations est divisé de la même manière. Les migrations centrales s’exécutent une fois. Les migrations tenant s’exécutent une fois par base tenant, et un déploiement qui ajoute une colonne aux factures exécute cette migration sur chaque entreprise, l’une après l’autre. La séparation est imposée par répertoire, donc une migration ne peut pas se retrouver du mauvais côté par accident.
Résoudre le tenant à chaque requête
Chaque tenant a au moins un domaine : un sous-domaine sur notre plateforme par défaut, et un domaine personnalisé dès qu’il en connecte un. Le pipeline de requête examine l’hôte, trouve le tenant et bascule la connexion de base de données par défaut avant qu’aucun code applicatif ne s’exécute. Les contrôleurs, modèles et services ne mentionnent jamais le tenant. Ils interrogent « la base de données », et la base de données est la bonne.
La partie subtile, c’est tout ce qui ne s’exécute pas dans une requête. Les jobs en file d’attente, les commandes planifiées, les listeners d’événements déclenchés après la réponse et la boucle d’outils de l’assistant IA ont tous besoin de savoir à quel tenant ils appartiennent. Le package de tenancy sérialise l’identifiant du tenant sur chaque job mis en file et réinitialise la connexion à l’exécution du job, de sorte qu’un job « envoyer un rappel de facture » dispatché dans la requête du tenant A s’exécute contre la base du tenant A une heure plus tard. Pour le travail planifié qui s’étend sur plusieurs tenants, une commande parcourt le registre et initialise chaque tenant à son tour.
La règle que nous imposons en revue de code est simple : si ça touche des données tenant et que ce n’est pas dans une requête, ça doit être dans un contexte tenant, et le test doit le prouver.
Des modules en packages Composer autonomes
Zenpresa vend ses fonctionnalités à la carte, donc la base de code est organisée de la même façon. Chaque module — planning, facturation, paiements, fichier clients, marketing, créateur de site, assistant IA et plusieurs plus petits — est un package Composer autonome vivant dans un répertoire modules/, branché via un path repository. Chacun a son propre composer.json, son propre namespace et son propre service provider que Laravel découvre automatiquement.
L’application elle-même reste un seul livrable : un processus Laravel, une image de conteneur, un déploiement. C’est un monolithe modulaire, pas des microservices. Nous obtenons les bénéfices organisationnels des modules (ownership clair, frontières claires, possibilité d’extraire un module plus tard) sans le coût opérationnel des systèmes distribués.
Des bénéfices pratiques que nous n’avions pas anticipés :
- PHPStan et la suite de tests s’exécutent par module. Un changement dans la facturation lance d’abord les tests de la facturation. Les tests inter-modules existent, mais ils constituent un palier séparé, plus lent.
- La direction des dépendances est visible. Le
composer.jsond’un module déclare ce dont il dépend. Si le marketing a besoin des clients, c’est une dépendance déclarée. Si les clients se mettaient à avoir besoin du marketing, le cycle apparaîtrait dans le manifeste, pas six mois plus tard au cours d’une session de débogage. - L’extraction est une vraie option. Nous n’avons pas extrait de module vers un service séparé, et nous n’en aurons peut-être jamais besoin. Mais les frontières sont déjà tracées, ce qui vaut plus que l’extraction elle-même.
Ce qu’un module possède
Un module possède tout ce qui concerne sa fonctionnalité :
- Les routes de ses endpoints API, enregistrées par son service provider sous le préfixe du module.
- Les migrations tenant de ses tables, découvertes depuis le répertoire de migrations du module lui-même.
- Les modèles, services et jobs dans le namespace du module.
- Les pages frontend dans le répertoire React correspondant, chargées à la demande pour qu’un tenant sans le module ne télécharge jamais son code.
- Les outils IA que l’assistant peut appeler, chacun étiqueté avec la clé du module pour n’apparaître que lorsque le module est activé.
- Une clé de fonctionnalité qui relie le tout.
Activer un module pour un tenant est un flag d’abonnement. Un middleware vérifie le flag sur chaque requête vers une route du module et renvoie une réponse explicite « module non activé » plutôt qu’une 404. Le frontend lit les mêmes flags pour décider quels éléments de navigation et quelles pages afficher. Le registre d’outils IA les lit pour décider quels outils le modèle peut voir. Un flag, trois points d’application, tous lisant la même source de vérité.
Les compromis que nous avons acceptés
Une base par tenant n’est pas gratuite, et il serait malhonnête de la présenter ainsi.
Des migrations sur N bases. Un déploiement qui modifie une table tenant exécute la migration contre chaque tenant. Avec un nombre modeste de tenants, cela prend quelques secondes. Avec plusieurs milliers, cela prend un temps réel et doit résister à un échec à mi-parcours. Nous exécutons les migrations tenant comme un job en file d’attente, reprenable, qui enregistre la progression par tenant, et nous concevons les migrations de façon additive pour qu’une flotte partiellement migrée reste fonctionnelle.
Le reporting inter-tenants est plus difficile. « Combien de rendez-vous ont été réservés sur toute la plateforme le mois dernier ? » tient en une ligne dans un schéma partagé et demande un fan-out dans le nôtre. Nous poussons les agrégats de niveau plateforme dans la base centrale selon un planning plutôt que d’interroger les tenants en direct. L’échange en vaut la peine : le reporting est une préoccupation planifiée, l’isolation une préoccupation de chaque requête.
La gestion des connexions. Les connexions Postgres ne sont pas gratuites, et un processus unique qui sert de nombreux tenants pendant la vie d’un worker doit réutiliser les connexions avec soin. Le package de tenancy gère la bascule ; nous avons dû ajuster les tailles de pool et nous assurer que les workers de longue durée ne fuient pas une connexion par tenant touché.
Le développement local est plus lourd. Un environnement de dev tout neuf doit provisionner au moins une base tenant avant que quoi que ce soit ne fonctionne. Nous avons enveloppé cela dans une seule commande, mais cela reste plus que migrate --seed.
La classe de bugs qui survit
L’isolation par connexion supprime le bug du scope oublié pour les tables tenant. Elle ne le supprime pas pour les tables centrales à portée tenant.
Certaines données sont légitimement centrales mais appartiennent à un tenant précis : ses domaines, son abonnement, ses clés API de niveau plateforme, le journal d’audit de son provisionnement. Ces lignes vivent dans la base centrale avec une colonne d’identifiant tenant, et toute requête sur celles-ci depuis un contexte tenant a toujours besoin du prédicat. Le package de tenancy ne peut pas aider ici, parce que la connexion centrale n’est, par conception, pas liée à un tenant.
C’est exactement la forme de bug qu’un fixture de test à tenant unique ne peut pas attraper. Un test qui provisionne un tenant, interroge la table centrale des domaines et vérifie que le résultat contient le domaine du tenant passera que le prédicat soit présent ou non, parce qu’il n’y a que les données d’un seul tenant à renvoyer. Le test doit provisionner deux tenants et vérifier que les lignes du second n’apparaissent pas. Nous avons désormais un cas de test de base qui fait exactement cela pour chaque table centrale dotée d’une colonne tenant, et un point de la checklist de revue qui demande « ces données centrales sont-elles à portée tenant, et le test prouve-t-il que l’autre tenant est exclu ? »
La leçon se généralise au-delà de notre pile : quelle que soit votre frontière d’isolation, trouvez les données qui vivent en dehors, et testez-les avec deux tenants, jamais un seul.
Où cela paie plus tard
Deux décisions qui découlent directement de l’architecture se sont remboursées plusieurs fois.
La première, c’est l’assistant IA. Parce que la connexion est liée au tenant, chaque outil que l’assistant appelle est isolé par construction. L’outil ne filtre pas par tenant ; il ne peut tout simplement pas atteindre la base d’un autre tenant. Cela nous a permis de concentrer le travail de sécurité de l’assistant sur les problèmes qui subsistent réellement, que les prochains billets de cette série couvrent.
La seconde, c’est le contrôle des coûts. Le comptage par tenant de l’usage de l’assistant, les limites de débit par tenant et les plafonds par tenant sont tous naturels quand le tenant est un concept de premier ordre à l’exécution plutôt qu’une colonne. Les patterns de notre précédent billet sur la limitation de débit et le contrôle des coûts des API LLM s’y insèrent sans adaptation.
Si vous construisez une plateforme où les données des clients ne doivent jamais se toucher, et que vous pouvez vous permettre la charge opérationnelle, nous referions le même choix. Si vous construisez une application grand public avec des millions de comptes minuscules, vous ne devriez probablement pas. La bonne réponse dépend de ce qu’une fuite coûterait, et pour les entreprises que Zenpresa sert, la réponse est : tout.
À suivre dans la série : Construire un assistant agentique dans Laravel avec le SDK PHP Anthropic : outils, voies de modèles, prompt caching et comptage par tenant
FAQ : une base de données par tenant sur Laravel
Une base par tenant est-elle plus lente qu’un schéma partagé ?
Par requête, non. Une fois la connexion basculée, les requêtes touchent une base plus petite avec un working set plus petit, ce qui est généralement plus rapide qu’une table partagée avec un prédicat tenant sur chaque index. Les coûts sont ailleurs : les migrations s’exécutent une fois par tenant, le reporting inter-tenants demande un fan-out ou un agrégat planifié, et le pooling de connexions demande de l’attention dans les workers de longue durée.
Comment les jobs en file d’attente savent-ils à quel tenant ils appartiennent ?
La couche de tenancy sérialise l’identifiant du tenant sur chaque job dispatché depuis un contexte tenant et réinitialise le tenant, y compris la connexion à la base de données, à l’exécution du job. La règle que nous imposons est que tout code touchant des données tenant en dehors d’une requête doit s’exécuter dans un contexte tenant explicite, et que son test doit le prouver.
Qu’est-ce qu’un monolithe modulaire et pourquoi pas des microservices ?
Un monolithe modulaire est une application déployable unique dont le code est organisé en modules strictement délimités, dans notre cas des packages Composer autonomes avec leurs propres routes, migrations, modèles et service providers. Nous obtenons un ownership clair et une direction des dépendances explicite sans la surcharge d’un système distribué. L’extraction en services séparés reste possible parce que les frontières existent déjà.
Une base par tenant élimine-t-elle les fuites de données inter-tenants ?
Pour les données dans les bases tenant, oui, par construction. Pour les tables centrales qui portent un identifiant tenant — domaines, abonnements, clés API plateforme — le prédicat reste nécessaire et le package de tenancy ne peut pas l’imposer. Testez ces tables avec deux tenants et vérifiez que les lignes du second sont exclues ; un fixture à tenant unique passera que le filtre existe ou non.
Comment gérez-vous une migration qui échoue à mi-chemin sur des milliers de bases tenant ?
Les migrations tenant s’exécutent comme un job en file d’attente, reprenable, qui enregistre la progression par tenant, donc un échec reprend au tenant qui a échoué plutôt que de tout recommencer. Les migrations sont conçues de façon additive pour qu’une flotte partiellement migrée continue de servir les requêtes correctement pendant que le job rattrape son retard.
Articles Connexes
Construire un assistant agentique dans Laravel avec le SDK PHP Anthropic : outils, voies de modèles, prompt caching et comptage par tenant
L'assistant IA de Zenpresa sur le SDK PHP Anthropic et Laravel : contrat d'outil strict, voies de modèles, prompt caching, SSE et comptage par tenant.
Découvrez Zenpresa : tout ce qui fait tourner votre entreprise de services, sous votre propre marque
Zenpresa, plateforme modulaire pour entreprises de services : planning, facturation, paiements, clients et site web sous votre marque, assistant IA inclus.
Les checks CI qu'un linter ne peut pas faire : contrat d'API, patterns mobiles, cliquet d'avertissements et tests qui épinglent les bugs Stripe
Quatre checks CI nés de vrais bugs chez Zenpresa : contrat d'API, patterns mobiles JSX, cliquet d'avertissements et tests épinglant les bugs Stripe.