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

bash
./bin/niang tenancy:install   # migration de la table tenants (name, slug, domain)
./bin/niang migrate
.env
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

php
// 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.

routes/web.php
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_BYLe locataire vient de
route (défaut)paramètre de route {tenant} (sous-domaine ou préfixe), colonne slug
domainhôte complet de la requête, colonne domain (domaines personnalisés des clients)
headeren-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

php
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 du request_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/).

⏱ 7.92 ms 🗄 0 requête(s) SQL 🧠 4.00 MB ↩ 200