Filament v4 Many-to-Many Relation Managers — Pivot Fields, Attach, and Detach

Build a Filament v4 many-to-many relation manager with pivot fields, AttachAction, recordSelectOptionsQuery, withPivot, and locked-down detach in 50 minutes.

Steven Richardson
Steven Richardson
· 10 min read

Most real Laravel schemas hide their best data in pivot tables. The role on a team membership, the price on a course enrolment, the expires_at on a subscription tier — none of it lives on the related model, and a default Filament relation manager pretends none of it exists. You drop down to raw Eloquent and lose the panel polish, or fork the relation manager class and hand-roll a custom modal.

Filament v4's relation managers expose pivot columns in the table, the attach modal, and validation with about thirty lines of code. This walkthrough builds a UsersRelationManager against a Team resource — pivot role and joined_at — and locks down the detach side so a button click can't shred audit history.

Add withPivot() to both sides of the BelongsToMany relationship#

Filament reads and writes pivot attributes through Eloquent. If withPivot() isn't declared on the relationship, the column simply doesn't appear on the loaded records, and any field you wire into the attach modal will be silently dropped on save. The trap is that you have to declare it on the inverse side too — the inverse is what the relation manager loads when it shows the table, and a one-sided declaration breaks the table half of the screen while the modal half keeps working. (For the wider tenant model this kind of pivot sits inside, the complete guide to Laravel 13 multi-tenancy with teams covers the URL-scoped currentTeam boundary that surrounds it.)

// app/Models/Team.php

public function users(): BelongsToMany
{
    return $this->belongsToMany(User::class)
        ->withPivot(['role', 'joined_at'])
        ->withTimestamps();
}

// app/Models/User.php

public function teams(): BelongsToMany
{
    return $this->belongsToMany(Team::class)
        ->withPivot(['role', 'joined_at'])
        ->withTimestamps();
}

withTimestamps() only kicks in if you've added created_at/updated_at to the pivot migration — Filament doesn't add them for you. If role is a backed enum (recommended; see PHP backed enums in Laravel models for the full pattern), set the cast on a custom Pivot model and tell the relationship to ->using(TeamUser::class). Without using(), the pivot row is hydrated as a generic Pivot instance and your enum cast never runs.

Generate the relation manager with --attach#

The --attach flag scaffolds the right starting point for many-to-many — header actions for CreateAction and AttachAction, row actions for EditAction, DetachAction, and DeleteAction, plus a DetachBulkAction and DeleteBulkAction in the toolbar group. Without the flag you get the BelongsTo/HasMany layout and you'll spend ten minutes deleting things and adding back the attach action by hand.

php artisan make:filament-relation-manager TeamResource users name --attach

The three positional arguments are the parent resource (TeamResource), the relationship method (users), and the recordTitleAttribute used in the table and select dropdowns (name). Filament will then prompt: "Should the configuration be generated from the current database columns?" — answer yes if the related table is settled, otherwise no and you'll get a stubbed form() and table() you can fill in.

Open the generated app/Filament/Resources/Teams/RelationManagers/UsersRelationManager.php and you'll see two key methods: form(Schema $schema): Schema for the create/edit modal, and table(Table $table): Table for the listing. The Schema parameter is new in v4 — it replaces v3's Form and is what lets the same configuration class power forms, infolists, and the new schema components on the same page.

Configure the table to display pivot columns#

By default the generated table shows the related model's columns and ignores the pivot row. To surface a pivot attribute you reference it as a top-level column name, not via dot notation — Filament loads the pivot attributes onto the related model when withPivot() is declared, so TextColumn::make('role') resolves to $user->pivot->role for you. Use pivot.relation.name only when you have to walk a relationship through the pivot itself.

public function table(Table $table): Table
{
    return $table
        ->recordTitleAttribute('name')
        ->columns([
            TextColumn::make('name')
                ->searchable()
                ->sortable(),
            TextColumn::make('email')
                ->searchable(),
            TextColumn::make('role')
                ->badge()
                ->color(fn (string $state): string => match ($state) {
                    'owner' => 'danger',
                    'admin' => 'warning',
                    default => 'gray',
                }),
            TextColumn::make('joined_at')
                ->dateTime('d M Y')
                ->sortable(),
        ])
        ->headerActions([
            CreateAction::make(),
            AttachAction::make()
                ->preloadRecordSelect(),
        ])
        ->recordActions([
            EditAction::make(),
            DetachAction::make()
                ->requiresConfirmation(),
            DeleteAction::make(),
        ])
        ->toolbarActions([
            BulkActionGroup::make([
                DetachBulkAction::make()
                    ->requiresConfirmation(),
                DeleteBulkAction::make(),
            ]),
        ]);
}

preloadRecordSelect() swaps the lazy AJAX search on the AttachAction dropdown for an upfront load of every attachable record. It's the right call when the related table is small (under a few hundred rows) — beyond that, leave the default search behaviour on or you'll ship a payload that takes longer to render than the rest of the page.

Add pivot fields to the AttachAction modal#

The default AttachAction shows one select. To collect pivot values at the same time, replace the default schema with one that starts from the action's record-select component and appends your pivot fields. The closure receives the live AttachAction instance, and $action->getRecordSelect() returns the configured select with all the search and scoping you've set elsewhere — don't hand-roll a Select::make() here or you'll lose every option scoping you wired up later.

AttachAction::make()
    ->preloadRecordSelect()
    ->schema(fn (AttachAction $action): array => [
        $action->getRecordSelect(),
        Select::make('role')
            ->options([
                'member' => 'Member',
                'admin' => 'Admin',
                'owner' => 'Owner',
            ])
            ->default('member')
            ->required(),
        DateTimePicker::make('joined_at')
            ->default(now())
            ->required(),
    ])
    ->recordSelectOptionsQuery(
        fn (Builder $query) => $query->whereNull('email_verified_at')->orWhereNotNull('email_verified_at')
    ),

Anything you put in that schema that matches a column listed in withPivot() gets written to the pivot row on save. Anything that doesn't match is silently ignored. That's the most common silent-failure I see: someone adds Toggle::make('is_lead') to the modal, forgets to add is_lead to withPivot(), and spends an hour wondering why the database isn't updating.

recordSelectOptionsQuery() scopes which records can be attached at all. Common patterns: filter to the current tenant (whereBelongsTo(Filament::getTenant())), exclude records already attached (whereDoesntHave('teams', fn ($q) => $q->where('teams.id', $this->ownerRecord->id))), or restrict by role (whereHas('roles', fn ($q) => $q->where('name', 'staff'))). One closure parameter — Builder $query — and you're scoping with the same Eloquent vocabulary as the rest of your app.

Add validation rules to the pivot fields#

Pivot fields validate exactly like any other form input. Chain ->required(), ->rules([...]), or pass a closure. The thing to watch is uniqueness — Rule::unique() against the pivot table needs the right where clause because the same user can be on many teams, just not on the same team twice with the same role.

Select::make('role')
    ->options(Role::class)
    ->required()
    ->rules([
        fn (AttachAction $action): Closure => function (string $attribute, $value, Closure $fail) use ($action): void {
            $teamId = $action->getRelationship()->getParent()->getKey();
            $userId = $action->getFormData()['recordId'] ?? null;

            $exists = DB::table('team_user')
                ->where('team_id', $teamId)
                ->where('user_id', $userId)
                ->where('role', $value)
                ->exists();

            if ($exists) {
                $fail('This user already has the :attribute role on this team.');
            }
        },
    ]),

The $action->getRelationship()->getParent() call returns the team being managed, and $action->getFormData()['recordId'] gives you the user that's about to be attached. Together they're enough to write any uniqueness or business-rule check you want before the pivot insert runs.

Lock down DetachAction and bulk operations#

Detach is a one-click data-loss path. On a regulated relationship — billing memberships, audit subjects, anything tied to compliance — disable the action entirely and route the user through a soft-archive flow that writes a tombstone instead. On everything else, at least force a confirmation modal with explicit copy.

->recordActions([
    EditAction::make(),
    DetachAction::make()
        ->requiresConfirmation()
        ->modalHeading('Remove member from team')
        ->modalDescription('They lose access immediately. Pivot history is kept for 30 days.')
        ->modalSubmitActionLabel('Remove member')
        ->visible(fn ($record): bool => $record->pivot->role !== 'owner'),
])
->toolbarActions([
    BulkActionGroup::make([
        DetachBulkAction::make()
            ->requiresConfirmation()
            ->visible(fn (): bool => auth()->user()->can('detach-team-members')),
    ]),
]),

Three things pull their weight here. requiresConfirmation() plus the three modal text knobs (modalHeading, modalDescription, modalSubmitActionLabel) make the destructive action read like a real prompt instead of "Are you sure?" boilerplate. visible() on the row action hides the button on rows where detach makes no sense — owners in this case. And the bulk action lives behind a policy check, so you don't need to remember the rule each time you ship a new admin role.

If you're keeping pivot history for audit, write a custom action that calls ->using(...) on the relationship to flip a removed_at timestamp instead of deleting the row. The detach button stays in the UI for the muscle memory; the underlying behaviour just becomes a soft-detach.

Gotchas and Edge Cases#

The make:filament-relation-manager generator has a known issue (filamentphp/filament#18678) where --attach is occasionally ignored on certain relationship configurations — Filament writes a non-attach relation manager and you have to add the AttachAction by hand. If the generated file has no AttachAction::make() in headerActions(), that's the bug, not user error.

AttachAction and the attach() Eloquent call ignore form fields that aren't in withPivot(). There's no warning, no log line, no failed validation — the value just isn't there in the database afterwards. Every single time I add a pivot field to a Filament form I now grep withPivot on the related models before saving the file.

Pivot model classes (->using(TeamUser::class)) work but they don't auto-cast pivot attributes the way $casts on a regular model does. Set $casts on the pivot model class and Filament will respect them for both reading (the table column) and writing (the modal save). Without it, your enum or datetime column round-trips as a raw string.

recordSelectOptionsQuery() runs every time the modal opens, which is fine for most cases but will hit the database hard if your scoping does an whereDoesntHave against a large pivot table. Add a database index on the pivot's foreign keys before you ship — team_user.team_id and team_user.user_id, ideally a compound — and the query stays sub-millisecond at scale.

Wrapping Up#

A relation manager with pivot fields is one of those features that feels like it should be obvious and then takes an afternoon to get right because the rules are spread across three doc pages. The shape is small once you've seen it: withPivot() on both sides, --attach to scaffold, the schema closure with getRecordSelect() first, and a confirmation modal on detach. Past that, it's just another form.

For the testing layer — Pest tests that boot the relation manager, attach a record, and assert the pivot data — the patterns in Pest architecture testing for Laravel apps and Pest 4 browser testing with Playwright are the right next reads. Both work the same against a relation manager as they do against any other Filament page.

FAQ#

How do I create a many-to-many relation manager in Filament v4?

Run php artisan make:filament-relation-manager {ResourceName} {relationshipName} {recordTitleAttribute} --attach from the project root. The --attach flag scaffolds the relation manager with CreateAction and AttachAction in the header and EditAction, DetachAction, and DeleteAction on each row, which is the right starting point for BelongsToMany. Filament will offer to generate the form and table from your database columns — accept it if your schema is settled.

How do I show pivot attributes in a Filament relation manager table?

Add withPivot(['column1', 'column2']) to both sides of the belongsToMany relationship — this is required, not optional. Then reference the pivot column in the relation manager's table() method as a top-level column: TextColumn::make('role'). Filament loads pivot attributes onto the related model so dot notation isn't needed unless you're walking a relationship that lives through the pivot, in which case use pivot.relation.name.

How do I edit pivot data in a Filament attach modal?

Pass a closure to the AttachAction's ->schema() method that returns the record-select component followed by your pivot fields: ->schema(fn (AttachAction $action): array => [$action->getRecordSelect(), Select::make('role')->required()]). Always start with $action->getRecordSelect() so you keep all the search and option-scoping behaviour wired up elsewhere. Any field whose name matches a column in withPivot() gets written to the pivot row on save.

What is the difference between Attach and Create in a Filament relation manager?

AttachAction lets the user pick an existing related record and write the pivot row; the related record itself isn't created. CreateAction lets the user fill out the related model's full form and creates a brand-new related record plus the pivot row in one operation. Use AttachAction when the related table is shared (assigning an existing user to a team), and CreateAction when the related row is owned by this parent (adding a line item to an invoice).

How do I prevent users from detaching records in Filament?

Either remove DetachAction and DetachBulkAction from the relation manager's actions arrays entirely, or hide them behind a policy check with ->visible(fn (): bool => auth()->user()->can('detach-members')). For destructive actions you keep, always chain ->requiresConfirmation() plus ->modalHeading(), ->modalDescription(), and ->modalSubmitActionLabel() so the prompt reads like a real warning rather than generic boilerplate. For audit-grade records, replace detach with a custom action that flips a removed_at timestamp instead of deleting the pivot row.

How do I add custom validation to pivot fields in Filament?

Pivot fields validate the same as any other form input — chain ->required(), ->rules(['unique:team_user,role,NULL,id,team_id,' . $teamId]), or pass a closure rule for ad-hoc logic. Inside the closure you can call $action->getRelationship()->getParent() to get the parent record and $action->getFormData() to read the values being submitted, which is enough to write any uniqueness or business-rule check before the pivot insert runs.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.