Gérer une relation Many-to-Many avec métadonnées dans FilamentPHP
Tuto actionnable pour gérer une relation Many-to-Many avec champs de pivot dans Filament v3: affichage, tri, filtres, attach/detach et édition des métadonnées.
Sommaire
Gérer une relation Many-to-Many avec métadonnées dans FilamentPHP
Vous voulez gérer une relation Many-to-Many qui transporte des champs “pivot” (quantité, note, date de début) directement depuis Filament v3, avec affichage, tri, filtres, attache/détache et édition des métadonnées ? Ce tutoriel vous guide pas à pas, en partant de la modélisation Eloquent jusqu’aux actions et validations robustes dans l’interface Filament.
Objectif
L’objectif est de mettre en place une ressource Filament capable de gérer une relation Many-to-Many avec des champs de pivot dans Filament v3. Au terme du tutoriel, vous pourrez afficher les métadonnées dans une table, les trier et les filtrer, attacher/détacher des enregistrements avec un formulaire, éditer les métadonnées, et sécuriser le tout avec validations et transactions.
Objectif et périmètre
Nous allons construire une ProductResource qui gère la relation belongsToMany entre Product et Feature via une table pivot product_feature contenant les colonnes quantity, note et starts_at. Nous couvrons les migrations, modèles Eloquent (y compris un modèle Pivot dédié), un RelationManager BelongsToMany, des colonnes triables/filtrables sur le pivot, des actions Attach/Detach, une action personnalisée pour éditer le pivot, les validations, les transactions, ainsi qu’un exemple pour synchroniser depuis le formulaire principal. On suppose un projet Laravel 10/11, Filament v3 déjà installé, et une familiarité avec Eloquent et les policies.
Modéliser la relation et le pivot
Commencez par créer les migrations et modèles. Ici, nous choisissons explicitement la table pivot product_feature (non standard alphabétique), pour coller à nos exemples de tri/filtrage.
Bash:
php artisan make:migration create_products_table
php artisan make:migration create_features_table
php artisan make:migration create_product_feature_table
php artisan make:model Product
php artisan make:model Feature
Migrations:
// database/migrations/xxxx_xx_xx_xxxxxx_create_products_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void {
Schema::create('products', function (Blueprint $table) {
$table->id();
$table->string('name', 120);
$table->timestamps();
});
}
public function down(): void {
Schema::dropIfExists('products');
}
};
// database/migrations/xxxx_xx_xx_xxxxxx_create_features_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void {
Schema::create('features', function (Blueprint $table) {
$table->id();
$table->string('name', 120);
$table->timestamps();
});
}
public function down(): void {
Schema::dropIfExists('features');
}
};
// database/migrations/xxxx_xx_xx_xxxxxx_create_product_feature_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void {
Schema::create('product_feature', function (Blueprint $table) {
$table->foreignId('product_id')->constrained()->cascadeOnDelete();
$table->foreignId('feature_id')->constrained()->cascadeOnDelete();
$table->unsignedInteger('quantity');
$table->text('note')->nullable();
$table->dateTime('starts_at')->nullable();
$table->timestamps();
$table->primary(['product_id', 'feature_id']);
$table->index('starts_at');
});
}
public function down(): void {
Schema::dropIfExists('product_feature');
}
};
Créez un modèle Pivot dédié pour centraliser les casts et, au besoin, des règles ou événements:
php artisan make:model ProductFeature
// app/Models/ProductFeature.php
namespace App\Models;
use Illuminate\Database\Eloquent\Relations\Pivot;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ProductFeature extends Pivot
{
protected $table = 'product_feature';
public $incrementing = false;
protected $fillable = ['quantity', 'note', 'starts_at'];
protected $casts = [
'quantity' => 'integer',
'starts_at' => 'datetime',
'note' => 'string',
];
public function product(): BelongsTo
{
return $this->belongsTo(Product::class);
}
public function feature(): BelongsTo
{
return $this->belongsTo(Feature::class);
}
}
Définissez les relations dans les modèles principaux, en précisant la table pivot et le modèle pivot:
// app/Models/Product.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Product extends Model
{
protected $fillable = ['name'];
public function features(): BelongsToMany
{
return $this->belongsToMany(Feature::class, 'product_feature')
->using(ProductFeature::class)
->withPivot(['quantity', 'note', 'starts_at'])
->withTimestamps();
}
}
// app/Models/Feature.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Feature extends Model
{
protected $fillable = ['name'];
public function products(): BelongsToMany
{
return $this->belongsToMany(Product::class, 'product_feature')
->using(ProductFeature::class)
->withPivot(['quantity', 'note', 'starts_at'])
->withTimestamps();
}
}
Les casts pivot garantissent que quantity est un entier, starts_at un datetime et note une chaîne, ce qui simplifie à la fois l’affichage et la validation en aval.
Générer la Resource Filament et le RelationManager
Générez la resource Filament et les pages de base:
php artisan make:filament-resource Product
Ajoutez un RelationManager pour la relation features:
php artisan make:filament-relation-manager ProductResource features
Dans ProductResource, déclarez le RelationManager pour que Filament l’affiche sur l’onglet dédié du produit:
// app/Filament/Resources/ProductResource.php
namespace App\Filament\Resources;
use App\Filament\Resources\ProductResource\Pages;
use App\Filament\Resources\ProductResource\RelationManagers\FeaturesRelationManager;
use App\Models\Product;
use Filament\Forms\Form;
use Filament\Forms\Components\TextInput;
use Filament\Resources\Resource;
use Filament\Tables\Table;
class ProductResource extends Resource
{
protected static ?string $model = Product::class;
public static function form(Form $form): Form
{
return $form->schema([
TextInput::make('name')->required()->maxLength(120),
]);
}
public static function table(Table $table): Table
{
// Table de liste des products si besoin
return $table;
}
public static function getRelations(): array
{
return [
FeaturesRelationManager::class,
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListProducts::route('/'),
'create' => Pages\CreateProduct::route('/create'),
'edit' => Pages\EditProduct::route('/{record}/edit'),
];
}
}
La base est en place: un produit possède un onglet pour gérer ses features via le RelationManager.
Afficher les colonnes pivot dans la table
Affichez les champs du pivot (quantity, note, starts_at) et rendez-les triables/recherchables. Vous pouvez récupérer la valeur via $record->pivot:
// app/Filament/Resources/ProductResource/RelationManagers/FeaturesRelationManager.php
namespace App\Filament\Resources\ProductResource\RelationManagers;
use App\Models\Feature;
use Filament\Forms;
use Filament\Tables;
use Filament\Tables\Table;
use Filament\Forms\Form;
use Filament\Tables\Columns\TextColumn;
use Filament\Resources\RelationManagers\BelongsToManyRelationManager;
use Illuminate\Database\Eloquent\Builder;
use Filament\Tables\Filters\Filter;
use Filament\Forms\Components\DatePicker;
use Illuminate\Support\Carbon;
class FeaturesRelationManager extends BelongsToManyRelationManager
{
protected static string $relationship = 'features';
protected static ?string $recordTitleAttribute = 'name';
public function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')
->label('Feature')
->searchable()
->sortable(),
TextColumn::make('pivot.quantity')
->label('Quantité')
->getStateUsing(fn (Feature $record) => $record->pivot->quantity)
->sortable(
query: fn (Builder $query, string $direction) =>
$query->orderBy('product_feature.quantity', $direction)
),
TextColumn::make('pivot.note')
->label('Note')
->getStateUsing(fn (Feature $record) => $record->pivot->note)
->wrap()
->searchable(
query: fn (Builder $query, string $search) =>
$query->where('product_feature.note', 'like', "%{$search}%")
),
TextColumn::make('pivot.starts_at')
->label('Débute le')
->getStateUsing(fn (Feature $record) => $record->pivot->starts_at)
->dateTime('Y-m-d H:i')
->placeholder('—'),
])
->modifyQueryUsing(
fn (Builder $q) => $q->withPivot(['quantity', 'note', 'starts_at'])
);
}
}
Ce réglage permet de trier sur product_feature.quantity et de rechercher dans product_feature.note, tout en formatant starts_at proprement et en affichant un tiret lorsque la valeur est nulle.
Attacher une Feature avec métadonnées (AttachAction)
Pour attacher une feature à un produit en saisissant immédiatement les métadonnées du pivot, enrichissez l’AttachAction avec un formulaire, des validations et une transaction. Voici un exemple d’action d’attache prête à l’emploi:
// suite de FeaturesRelationManager::table(...)
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\DateTimePicker;
use Illuminate\Support\Facades\DB;
use Filament\Resources\RelationManagers\RelationManager;
return $table
->headerActions([
Tables\Actions\AttachAction::make()
->preloadRecordSelect()
->recordSelectOptionsQuery(fn (Builder $query) => $query->orderBy('name'))
->form([
TextInput::make('quantity')
->label('Quantité')
->numeric()
->required()
->minValue(1),
Textarea::make('note')
->label('Note')
->maxLength(500),
DateTimePicker::make('starts_at')
->label('Débute le')
->seconds(false)
->nullable(),
])
->mutateFormDataUsing(
fn (array $data) => array_filter($data, fn ($v) => $v !== null && $v !== '')
)
->using(function (RelationManager $livewire, Feature $record, array $data) {
DB::transaction(function () use ($livewire, $record, $data) {
$livewire->getRelationship()->attach($record->getKey(), $data);
});
}),
]);
Dans cet exemple, quantity est requise et au moins 1, note est limitée à 500 caractères, starts_at est facultative. Le mutateFormDataUsing nettoie les champs vides, et la fermeture using enveloppe l’attache dans une transaction pour garantir l’intégrité.
Éditer les champs du pivot (action personnalisée)
L’édition des métadonnées après l’attache nécessite une action personnalisée qui pré-remplit le formulaire depuis $record->pivot et persiste les changements dans une transaction.
// suite de FeaturesRelationManager::table(...)
use Filament\Notifications\Notification;
->actions([
Tables\Actions\Action::make('editPivot')
->label('Éditer')
->icon('heroicon-m-pencil-square')
->mountUsing(function (Feature $record, Forms\ComponentContainer $form) {
$form->fill([
'quantity' => $record->pivot->quantity,
'note' => $record->pivot->note,
'starts_at' => optional($record->pivot->starts_at)?->format('Y-m-d H:i'),
]);
})
->form([
TextInput::make('quantity')
->label('Quantité')
->numeric()
->required()
->minValue(1),
Textarea::make('note')
->label('Note')
->maxLength(500),
DateTimePicker::make('starts_at')
->label('Débute le')
->seconds(false),
])
->action(function (Feature $record, array $data) {
DB::transaction(fn () => $record->pivot->update($data));
Notification::make()
->title('Métadonnées mises à jour')
->success()
->send();
})
->visible(fn (Feature $record) => auth()->user()?->can('update', $record->pivot) ?? false),
])
Pour gérer l’autorisation proprement, créez une policy pour le modèle pivot ProductFeature. Vous pourrez ainsi contrôler qui peut modifier un lien précis Product-Feature.
php artisan make:policy ProductFeaturePolicy --model=App\\Models\\ProductFeature
// app/Policies/ProductFeaturePolicy.php
namespace App\Policies;
use App\Models\ProductFeature;
use App\Models\User;
class ProductFeaturePolicy
{
public function update(User $user, ProductFeature $pivot): bool
{
// Exemple: autoriser les admins ou les propriétaires du produit
return $user->hasRole('admin')
|| method_exists($pivot->product, 'isOwnedBy') && $pivot->product->isOwnedBy($user);
}
public function delete(User $user, ProductFeature $pivot): bool
{
return $this->update($user, $pivot);
}
}
Et enregistrez-la si nécessaire dans AuthServiceProvider:
// app/Providers/AuthServiceProvider.php
protected $policies = [
\App\Models\ProductFeature::class => \App\Policies\ProductFeaturePolicy::class,
];
Détacher avec garde-fous (DetachAction)
Le détachement peut nécessiter des garde-fous (ex.: verrouillage conditionnel). Voici une action qui demande confirmation, accepte une justification, vérifie un verrou éventuel sur le pivot et notifie à la fin.
// suite de FeaturesRelationManager::table(...)
->actions([
Tables\Actions\DetachAction::make()
->before(function (Feature $record) {
abort_if(($record->pivot->is_locked ?? false) === true, 403, 'Association verrouillée');
})
->requiresConfirmation()
->modalDescription('Confirmez le détachement. Vous pouvez laisser une justification.')
->form([
Forms\Components\Textarea::make('reason')
->label('Justification')
->maxLength(500),
])
->action(function (Feature $record, array $data) {
DB::transaction(function () use ($record, $data) {
// Exemple: journaliser la justification en base si besoin.
$record->pivot->delete();
});
})
->after(fn () => Notification::make()->success()->title('Détaché')->send())
->visible(fn (Feature $record) => auth()->user()?->can('delete', $record->pivot) ?? false),
])
Ici, nous supprimons la ligne pivot en appelant delete() sur le modèle pivot, ce qui est adapté puisqu’on utilise ProductFeature. Vous pouvez aussi utiliser le détachement via la relation si vous préférez.
Filtres et scopes sur champs pivot
Les filtres Filament peuvent cibler des colonnes de la table pivot. Par exemple, un filtre “Avec quantité” applique quantity > 0, et un filtre période applique un whereBetween sur starts_at. Vous pouvez aller plus loin en exposant des scopes Eloquent.
// suite de FeaturesRelationManager::table(...)
->filters([
Filter::make('withQuantity')
->label('Avec quantité')
->query(fn (Builder $q) => $q->where('product_feature.quantity', '>', 0)),
Filter::make('période')
->form([
DatePicker::make('from')->label('Du'),
DatePicker::make('to')->label('Au'),
])
->query(function (Builder $q, array $data) {
$from = $data['from'] ?? null;
$to = $data['to'] ?? null;
if ($from && $to) {
$q->whereBetween('product_feature.starts_at', [
Carbon::parse($from)->startOfDay(),
Carbon::parse($to)->endOfDay(),
]);
} elseif ($from) {
$q->where('product_feature.starts_at', '>=', Carbon::parse($from)->startOfDay());
} elseif ($to) {
$q->where('product_feature.starts_at', '<=', Carbon::parse($to)->endOfDay());
}
}),
])
Pour simplifier la réutilisation côté Eloquent, vous pouvez aussi définir un scope sur la relation, par exemple: $product->features()->wherePivot('quantity', '>', 0). Filament affichera automatiquement des chips de filtres actifs, donnant un feedback immédiat à l’utilisateur.
Form principal: synchroniser les métadonnées depuis la page Edit
Si vous souhaitez éditer le Product et ses Features dans le même formulaire (sans passer par le RelationManager), un Repeater peut accueillir un Select de Feature et les champs pivot. À la sauvegarde, transformez le Repeater en payload pour sync().
Formulaire: ajoutez un Repeater dans ProductResource::form:
// app/Filament/Resources/ProductResource.php (extrait)
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Select;
use App\Models\Feature;
public static function form(Form $form): Form
{
return $form->schema([
TextInput::make('name')->required()->maxLength(120),
Repeater::make('featuresPayload')
->label('Caractéristiques')
->schema([
Select::make('feature_id')
->label('Feature')
->options(fn () => Feature::query()->orderBy('name')->pluck('name', 'id'))
->searchable()
->preload()
->required(),
TextInput::make('quantity')
->label('Quantité')
->numeric()
->required()
->minValue(1),
Textarea::make('note')
->label('Note')
->maxLength(500),
DateTimePicker::make('starts_at')
->label('Débute le')
->seconds(false)
->nullable(),
])
->default([]) // rempli lors de l'édition via mutateFormDataBeforeFill ci-dessous
->collapsible()
->reorderable(false),
]);
}
Dans la page d’édition, hydratez le Repeater depuis la relation, puis synchronisez après sauvegarde:
// app/Filament/Resources/ProductResource/Pages/EditProduct.php
namespace App\Filament\Resources\ProductResource\Pages;
use App\Filament\Resources\ProductResource;
use Filament\Resources\Pages\EditRecord;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Arr;
use Illuminate\Support\Carbon;
use Filament\Notifications\Notification;
class EditProduct extends EditRecord
{
protected static string $resource = ProductResource::class;
protected function mutateFormDataBeforeFill(array $data): array
{
$features = $this->record->features()
->withPivot(['quantity', 'note', 'starts_at'])
->get()
->map(fn ($f) => [
'feature_id' => $f->id,
'quantity' => $f->pivot->quantity,
'note' => $f->pivot->note,
'starts_at' => optional($f->pivot->starts_at)?->format('Y-m-d H:i'),
])
->values()
->all();
$data['featuresPayload'] = $features;
return $data;
}
protected function afterSave(): void
{
$rows = $this->data['featuresPayload'] ?? [];
// Validation simple des doublons côté application
$featureIds = array_map(fn ($row) => (int) ($row['feature_id'] ?? 0), $rows);
if (count($featureIds) !== count(array_unique($featureIds))) {
Notification::make()
->title('Chaque feature ne peut être sélectionnée qu’une seule fois.')
->danger()
->send();
return;
}
$payload = collect($rows)->mapWithKeys(function ($row) {
$starts = $row['starts_at'] ?? null;
return [
(int) $row['feature_id'] => [
'quantity' => (int) $row['quantity'],
'note' => $row['note'] ?? null,
'starts_at' => $starts ? Carbon::parse($starts) : null,
],
];
})->toArray();
DB::transaction(fn () => $this->record->features()->sync($payload));
Notification::make()
->title('Caractéristiques synchronisées')
->success()
->send();
}
}
Ce flux permet d’éditer tout en une seule page et de garantir l’unicité des features via une vérification application, en plus des validations des champs. Vous pouvez renforcer encore la validation avec un Validator dédié si nécessaire.
Performance, tests et pièges courants
Pensez à précharger la relation lorsque vous en avez besoin pour éviter les N+1, par exemple dans la liste des produits ou dans des pages surchargées. Par exemple, dans ProductResource::getEloquentQuery(), vous pouvez faire: Product::query()->with(['features' => fn ($q) => $q->withPivot(['quantity','note','starts_at'])]). Assurez-vous que les colonnes pivot sont correctement castées et modifiables: sur ProductFeature, déclarez $fillable et les $casts, et évitez un $guarded global trop restrictif. Testez systématiquement les cas d’attache/détache et d’édition pivot: l’heureux chemin, les validations qui échouent, et la reprise sur exception avec rollback, notamment en scénarisant un échec dans la transaction. Vérifiez vos index: une clé primaire composite (product_id, feature_id) accélère le upsert/détachage, et un index sur starts_at aide pour les tris et filtres fréquents. Côté sécurité, appuyez-vous sur des policies pour autoriser l’attache, l’édition et le détachement au niveau précis du pivot; l’usage d’un modèle pivot dédié facilite grandement ces contrôles.
Aller plus loin
Vous pouvez ajouter des actions de masse pour modifier quantity d’un groupe de lignes pivot en une seule opération. Par exemple, une BulkAction “Définir la quantité” peut afficher un simple formulaire et appliquer une mise à jour transactionnelle sur tous les pivots sélectionnés. Pour exporter la table avec les colonnes pivot, utilisez un plugin d’export (comme filament/excel) et mappez les champs du pivot en colonnes à plat afin d’inclure quantity, note et starts_at dans un CSV ou un Excel. Enfin, vous pouvez envoyer des notifications utilisateur ou déclencher des events de domaine après les modifications du pivot; par exemple, l’augmentation d’une quantité peut déclencher un recalcul ou un webhook dans votre application.
Checklist
Avant de boucler, relisez l’ensemble des changements et validez que l’UX est cohérente entre l’AttachAction et l’édition pivot; exécutez les commandes artisan pour générer les ressources et appliquez les migrations, puis testez chaque action (attache, édition, détache, filtres, tri) en conditions réelles, y compris les cas d’erreur et les permissions; enfin, publiez votre mise à jour après avoir vérifié les performances (requêtes, index) et la journalisation des opérations sensibles.
Ressources
- Documentation Filament Tables (v3): https://filamentphp.com/docs/3.x/tables/overview
- Relation Managers (BelongsToMany): https://filamentphp.com/docs/3.x/resources/relation-managers
- Actions Tables (Attach/Detach/Custom): https://filamentphp.com/docs/3.x/tables/actions
- Laravel Eloquent Many-to-Many: https://laravel.com/docs/eloquent-relationships#many-to-many
- Modèle Pivot Eloquent: https://laravel.com/docs/eloquent-relationships#custom-pivot-models
- Filament Notifications: https://filamentphp.com/docs/3.x/notifications/overview
- Filament Excel (export): https://filamentphp.com/plugins/excel
Conclusion
En modélisant proprement votre pivot et en exploitant RelationManager, Actions et Filters de Filament v3, vous pouvez offrir une gestion complète et ergonomique d’une relation Many-to-Many enrichie de métadonnées: affichage fidèle, tri et recherche sur les colonnes pivot, attache et édition cohérentes, détachement sécurisé et synchronisation depuis le formulaire principal. En ajoutant des policies ciblées, des transactions et quelques tests, vous obtenez une solution robuste, prête pour la production.