Multi-locataire
Multi-locataire
Une seule application, plusieurs clients (boutiques, écoles, entreprises) dont les données ne se mélangent jamais. Base partagée : chaque ligne porte l'identifiant de son locataire, filtré automatiquement.
Installation
./bin/niang tenancy:install # migration de la table tenants (name, slug, domain)
./bin/niang migrate
TENANCY_ENABLED=true
Désactivé par défaut : sans TENANCY_ENABLED, rien ne change. config/tenancy.php règle le
mode d'identification, le nom de la table et celui de la colonne (tenant_id).
niang doctor signale une table des locataires manquante.
Modèles par locataire
// migration
Schema::create('projects', function ($table) {
$table->id();
$table->foreignId('tenant_id')->constrained('tenants');
$table->string('name');
$table->timestamps();
});
// app/Models/Project.php
class Project extends Model
{
protected static array $fillable = ['name'];
protected static bool $tenantScoped = true;
}
Project::all(); // uniquement les projets du locataire courant
Project::create(['name' => 'Site']); // tenant_id renseigné automatiquement
Project::find($idDUnAutreLocataire); // null
Le filtre s'applique à tout ce qui passe par le modèle : query(), find(),
paginate(), update(), destroy(), relations et with(), pivots
compris. La colonne tenant_id est imposée à la création (une valeur fournie est remplacée) et
update() ne peut pas la changer : une ligne ne passe jamais chez un autre locataire.
Sûr par défaut. Un modèle par locataire interrogé sans locataire courant lève
TenancyException au lieu de renvoyer les lignes de tous les clients : une route qui oublie le
middleware échoue, elle ne fuit pas. Le SQL écrit à la main (DB::select(),
new QueryBuilder('projects')) n'est pas filtré.
Identifier le locataire
Le middleware App\Middleware\IdentifyTenant trouve le locataire de la requête, ou répond 404.
use App\Middleware\IdentifyTenant;
// Sous-domaine : dakar.exemple.sn, thies.exemple.sn
$router->domain('{tenant}.exemple.sn', function ($router) {
$router->group(['middleware' => [IdentifyTenant::class]], function ($router) {
$router->get('/projets', [ProjectController::class, 'index']);
});
});
// Ou préfixe d'URL : exemple.sn/dakar/projets
$router->group(['prefix' => '/{tenant}', 'middleware' => [IdentifyTenant::class]], function ($router) {
$router->get('/projets', [ProjectController::class, 'index']);
});
TENANCY_IDENTIFY_BY | Le locataire vient de |
|---|---|
route (défaut) | paramètre de route {tenant} (sous-domaine ou préfixe), colonne slug |
domain | hôte complet de la requête, colonne domain (domaines personnalisés des clients) |
header | en-tête X-Tenant, colonne slug (API) |
Identifier le locataire ne dit pas que l'utilisateur connecté en fait partie. Rendez la table
users elle-même par locataire, ou vérifiez l'appartenance (par exemple dans une
Policy) — indispensable avec header, que le client
choisit librement.
Le locataire courant
use Niang\Core\Tenancy;
Tenancy::current(); // la ligne de la table tenants, ou null
Tenancy::id();
// Commandes, seeders, tâches planifiées : agir pour un locataire
Tenancy::run($tenant, fn () => Project::create(['name' => 'Démo'])); // ligne ou identifiant
// Administration de la plateforme : tous les locataires
$total = Tenancy::central(fn () => Project::query()->count());
Cache, jobs, logs
- Cache : chaque locataire a ses propres clés ;
Cache::remember('stats', ...)ne renvoie jamais les statistiques d'un autre. Hors locataire, les clés sont communes. - Jobs : un job mis en file pendant une requête d'un locataire est exécuté par le worker pour ce même locataire.
- Logs : chaque message porte
tenant, en plus durequest_id. - Métriques : communes à la plateforme, jamais séparées par locataire.
Un exemple complet (organisations, rôles, invitations, abonnements) : le starter « saas ».
Ce qui n'est pas pris en charge
Une base de données ou un schéma par locataire : sessions, cache, jetons et file d'attente vivent dans la
même base que les données, et devraient rester sur une connexion centrale. Ce choix est expliqué dans
l'ADR 0011 du framework (docs/adr/).