Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Filament Belongs To Many Laravel Package

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+.

View on GitHub
Deep Wiki
Context7

Getting Started

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:

  • the field class name and namespace (this is what you'll use in your Resource)
  • whether it registers a service provider / config file (php artisan vendor:publish --provider="Wotz\...")
  • whether it wraps Filament's native BelongsToMany or reimplements the select — this determines every behavior below

3. 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.

Implementation Patterns

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.

Gotchas and Tips

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:

  1. $livewire->form->getState() — is the key present with an array of IDs?
  2. $record->getRelations() after save — did Filament call saveRelationships()?
  3. Is the field inside a Section/Grid that's conditionally hidden?
  4. Does 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.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
gmostafa/php-graphql-client
xeviant/paystack
snowsoft/faker
meditationsteps/blogetc
animalyoung/laravel-influxdb
onix-solutions/sms-telera
lonely-lights/prosetta
oxygen/data
oxygen/core
jengo/base
xiloryx/log-collector
a21ns1g4ts/filament-stripe
la-php/laravel-admin
artisan-build/community-prompts
vleroy/smart
ayles-software/laravel-australian-public-holidays
cboxdk/laravel-id-spatie
cboxdk/laravel-id-billing
cboxdk/laravel-id-billing-bridge
simple-as-fuck/php-performance-log