Multi-tenancy avec Filament v5 : isolation des données sans compromis
La question arrive inévitablement dans tout projet SaaS : comment faire cohabiter plusieurs clients dans une même application en garantissant que leurs données restent strictement séparées ? Filament embarque un système de multi-tenancy natif qui répond à ce besoin, mais la documentation officielle liste les méthodes disponibles sans expliquer comment les assembler correctement. Voici un retour d'expérience complet sur l'implémentation, les pièges à éviter et les patterns qui garantissent une isolation rigoureuse.
Cet article s'appuie sur le guide publié par Hafiz sur dev.to, enrichi de notre retour terrain chez MulerTech.
Ce que Filament entend par « multi-tenancy »
Premier point à clarifier : Filament ne propose pas d'isolation par base de données (à la manière de stancl/tenancy). Le modèle retenu est une base partagée avec une relation many-to-many entre utilisateurs et tenants.
Concrètement :
- Un utilisateur appartient à plusieurs équipes (tenants).
- Une équipe possède plusieurs utilisateurs.
- Chaque ressource métier (projets, factures, tickets…) est rattachée à une équipe via une clé étrangère.
Filament injecte automatiquement un filtre global sur toutes les requêtes Eloquent pour ne retourner que les enregistrements de l'équipe courante. Simple en apparence, mais redoutable à sécuriser correctement.
Mise en place : modèle, relations et panel
1. Le modèle Tenant
Le tenant est généralement une Team. Elle doit implémenter le contrat HasTenants côté utilisateur, et le modèle équipe doit implémenter HasMembers :
// app/Models/Team.php
use Filament\Models\Contracts\HasAvatar;
use Filament\Models\Contracts\HasCurrentTenantLabel;
use Illuminate\Database\Eloquent\Model;
class Team extends Model implements HasAvatar, HasCurrentTenantLabel
{
public function members(): BelongsToMany
{
return $this->belongsToMany(User::class);
}
public function getCurrentTenantLabel(): string
{
return $this->name;
}
}
Côté User, on déclare la relation inverse et on implémente HasTenants :
// app/Models/User.php
use Filament\Models\Contracts\HasTenants;
use Illuminate\Support\Collection;
class User extends Authenticatable implements HasTenants
{
public function teams(): BelongsToMany
{
return $this->belongsToMany(Team::class);
}
public function getTenants(Panel $panel): Collection
{
return $this->teams;
}
public function canAccessTenant(Model $tenant): bool
{
return $this->teams->contains($tenant);
}
}
La méthode canAccessTenant est critique : c'est elle qui empêche un utilisateur d'accéder à un tenant auquel il n'appartient pas en manipulant l'URL.
2. Configuration du panel Filament
Dans le PanelProvider, on active la tenancy en pointant vers le modèle :
// app/Providers/Filament/AdminPanelProvider.php
->tenant(Team::class, slugAttribute: 'slug')
->tenantRegistration(RegisterTeamPage::class)
->tenantProfile(EditTeamPage::class)
Filament va automatiquement :
- Préfixer toutes les URLs avec l'identifiant du tenant (
/admin/{team}/...). - Proposer un sélecteur de tenant dans le menu de navigation.
- Rediriger vers la page d'enregistrement si l'utilisateur n'appartient à aucune équipe.
Le piège principal : le scoping automatique ne suffit pas
Filament applique un scope global basé sur le tenant courant. Mais ce mécanisme a des angles morts qui peuvent compromettre l'isolation des données.
Les relations imbriquées
Imaginez une ressource Project scopée sur la team, et une ressource Task reliée à Project. Si Task ne déclare pas explicitement son scope, un utilisateur pourrait — via un identifiant deviné — accéder à des tâches d'une autre équipe.
Toujours chaîner le scope sur la relation parente :
// Dans TaskResource
public static function getEloquentQuery(): Builder
{
return parent::getEloquentQuery()
->whereHas('project', fn ($q) =>
$q->where('team_id', Filament::getTenant()->id)
);
}
Les actions et mutations
Les formulaires de création doivent injecter le team_id automatiquement. Ne pas compter sur l'utilisateur ni sur un champ caché côté front :
public function handleRecordCreation(array $data): Model
{
return static::getModel()::create([
...$data,
'team_id' => Filament::getTenant()->id,
]);
}
Les policies Laravel
Filament s'appuie sur les policies Laravel pour les vérifications d'autorisation. Il faut systématiquement vérifier que la ressource cible appartient bien au tenant courant, et non se contenter de vérifier l'appartenance de l'utilisateur :
public function update(User $user, Project $project): bool
{
return $user->teams->contains($project->team_id);
}
Enregistrement et gestion du profil tenant
Filament propose deux pages prêtes à l'emploi que l'on étend :
Filament\Pages\Tenancy\RegisterTenantpour la création d'une nouvelle équipe.Filament\Pages\Tenancy\EditTenantProfilepour modifier le nom, le logo, etc.
On les personnalise avec un simple Form Filament :
class RegisterTeamPage extends RegisterTenant
{
public static function getLabel(): string
{
return 'Créer une équipe';
}
public function form(Form $form): Form
{
return $form->schema([
TextInput::make('name')->required()->maxLength(255),
TextInput::make('slug')->required()->unique(),
]);
}
protected function handleRegistration(array $data): Team
{
$team = Team::create($data);
$team->members()->attach(auth()->user());
return $team;
}
}
Ne pas oublier d'attacher l'utilisateur courant à l'équipe qu'il vient de créer — sans ça, il se retrouve sans tenant valide et Filament le redirige en boucle.
Checklist de sécurité avant mise en production
Avant de déployer une application multi-tenant Filament, vérifiez ces points :
canAccessTenant()implémentée et testée avec un utilisateur sans relation.- Chaque ressource avec relations imbriquées scope explicitement sur le
team_id. - Les mutations (create/update) injectent le
team_idcôté serveur, jamais côté client. - Les policies Laravel couvrent les cas de ressources inter-tenants.
- Les tests d'intégration simulent l'accès d'un utilisateur d'une équipe A aux ressources de l'équipe B.
Conclusion
Le système de multi-tenancy de Filament est élégant et productif : il gère le switching, le scoping de base et les pages de gestion sans code superflu. Mais il ne dispense pas d'une réflexion rigoureuse sur les couches d'isolation complémentaires — policies, scopes sur relations imbriquées, injection serveur des identifiants de tenant.
Un audit des ressources Filament avec cette grille de lecture permet d'identifier rapidement les surfaces d'exposition avant qu'elles ne deviennent des failles. C'est précisément le type de revue que nous appliquons systématiquement sur nos projets SaaS chez MulerTech.