wotz/filament-belongs-to-many
A Filament form field for managing belongsToMany relationships with a searchable, sortable multi-select UI. Attach and detach related records inline, with support for pivot data and custom labels. A lightweight alternative to the default relationship component, built for Laravel and Filament v3+.
1. Install and pin.
composer require wotz/filament-belongs-to-many
Before writing a single line of form code, run the compatibility check — this package has one release (2026-07-23), zero dependents, and a Filament peer constraint that is very likely narrower than your app's:
composer why-not wotz/filament-belongs-to-many
composer show wotz/filament-belongs-to-many
If it resolves cleanly, immediately pin it ("wotz/filament-belongs-to-many": "1.0.*" or an exact version) rather than floating on ^1.0. With no downstream users you are the QA.
2. Read the source before the README. The package is small; vendor/wotz/filament-belongs-to-many/src/ is a 10-minute read and will tell you more than the docs:
use in your Resource)php artisan vendor:publish --provider="Wotz\...")BelongsToMany or reimplements the select — this determines every behavior below3. Confirm the schema matches HABTM conventions. Laravel expects the pivot table to be the two model names, singular, snake_case, alphabetically ordered:
posts tags
↳ post_tag (post_id, tag_id, primary([post_id, tag_id]))
If your pivot is tag_post or carries a model, either rename it or pass the table explicitly through the relationship — do not let the field guess.
4. First use case: a Post with many Tags.
use Wotz\FilamentBelongsToMany\...; // exact class from src/, verify
public static function form(Form $form): Form
{
return $form->schema([
TextInput::make('title')->required(),
static::habtmField()->make('tags')
->relationship('tags', 'name')
->searchable()
->preload()
->required(),
]);
}
Then verify the round trip in Tinker before touching the UI: create a post with two tags, $post->tags()->sync([...]), reload the edit page, remove one chip, save, and check post_tag in the DB. If step 4 works, the rest is styling.
Treat the field as the pivot editor, nothing more. Day-to-day the workflow is: BelongsToMany relation on the model → field in the Resource form → badge column in the table. Keep the field scoped to selection and put pivot payload (sort order, role, attached_at) in a separate, explicit surface.
Form usage with eager loading and search. The killer for HABTM selects is loading every option. Always pair ->searchable() with ->preload() only when the option set is genuinely small (<200); otherwise leave preload off so the search endpoint does the work:
->relationship('tags', 'name')
->searchable(['name', 'slug']) // search across columns, not just the label
->preload()
->optionsLimit(50)
Inline creation for taxonomy. If the field supports Filament's create-option modal, wire it — it's the highest-value integration because HABTM entities (tags, categories, authors) are usually created while editing the parent:
->createOptionForm([
TextInput::make('name')->required()->unique(),
TextInput::make('slug')->required(),
])
->createOptionUsing(fn (array $data) => Tag::create($data)->getKey())
If the field doesn't expose this, fall back to a Select + CreateAction pair rather than patching the package.
Table display. Don't render the field in the table. Use a native relationship column and skip a query per row:
TextColumn::make('tags.name')
->badge()
->separator(',')
->limitList(3),
and in getTableQuery()/modifyQueryUsing(): ->with('tags'). This sidesteps whatever query strategy the field uses internally.
Pivot-aware editing. When pivot columns matter, prefer a RelationManager with a pivot-aware form over cramming it into the field. Pattern: main form = relation sync (ids only), RelationManager = per-attachment metadata.
Seeders and factories. HABTM is where test fixtures silently break. Seed deterministically:
$post->tags()->syncWithoutDetaching(
Tag::factory()->count(3)->create()->pluck('id')
);
In tests, assert on the pivot, not the loaded relation (assertDatabaseHas('post_tag', [...])), because the relation is cached after the form save and will lie to you.
Livewire tests. Filament form state for the field is an array of IDs, so:
livewire(EditPost::class, ['record' => $post->getRouteKey()])
->fillForm(['tags' => [$a->id, $b->id]])
->call('save')
->assertHasNoFormErrors();
Authorization. HABTM rows bypass model policies by design — attach/detach aren't covered by a TagPolicy. If tag assignment is privileged, gate it in the Resource (canEdit, ->disabled(fn () => ! auth()->user()->can('assignTags', $record))) or the field will happily let anyone re-tag.
Maintenance risk is the headline. Zero stars, zero dependents, one release. Two mitigations, pick one now: (a) fork into app/Support/Filament/ and drop the dependency, or (b) pin the version and add a comment with the upgrade path. Filament's native BelongsToMany component covers the common case in v3 — check whether this package is solving a problem you actually still have before adopting it.
The save is a sync, not an attach. Standard Filament relationship behavior means removing a chip detaches the row. Two consequences: a field rendered ->dehydrated(false) or hidden by ->visible() conditional logic can end up syncing an empty array and wiping all associations. Confirm the field is present in the submitted payload before trusting a save.
Duplicate pivot rows. HABTM has no unique index unless you add it. Livewire re-renders and double-submits will happily insert twice, and then sync "fixes" it in a way that hides the bug. Add a composite primary/unique key in the migration — do it before you have production data.
Timestamps. If the pivot has withTimestamps(), sync only touches them on insert unless you pass attributes. If you need attached_at semantics, use a pivot model and explicit syncWithPivotValues.
Soft deletes on the related model. Filament's select and the package's query may or may not apply the global scope consistently. Related tags that get soft-deleted can vanish from the picker but stay in $post->tags — the edit form then silently drops them on save. Verify with a trashed tag.
Ordering. HABTM has no natural order; $post->tags comes back however SQL feels. If display order matters, sort in the column or add an order pivot column — don't expect the field to preserve selection order unless the README explicitly says so.
N+1 in the table. The classic symptom is a Resource list page doing one query per row for the badge column. ->with('tags') in the table query is the fix; check it with DB::listen or Debugbar once and never again.
Debugging checklist when saves don't stick:
$livewire->form->getState() — is the key present with an array of IDs?$record->getRelations() after save — did Filament call saveRelationships()?Section/Grid that's conditionally hidden?composer why-not actually pass, or is Composer silently resolving an older Filament?Extension points. If the source exposes it, a custom pivot model (->pivotModel(...) or via ->using()) is the cleanest place for business rules — validation, events on attach, audit columns. If it doesn't expose one, that's your fork trigger rather than hacking around the component.
Document the version in your codebase. A one-line comment above the field with the package version and the date you audited src/ will save the next developer (probably you) an hour when Filament upgrades.
How can I help you explore Laravel packages today?