lucasgiovanny/filament-multiselect-two-sides
A Filament form component that adds a two-sided multiselect field, letting users move options between available and selected lists with search and transfer controls for cleaner Laravel admin forms.
Install
composer require lucasgiovanny/filament-multiselect-two-sides
The service provider is auto-discovered by Filament — there is nothing to register in config/app.php or a panel provider.
Pick your line deliberately. There are now two tracks:
| Line | Target (verify in the tag's composer.json) |
Status |
|---|---|---|
^2.x (2.1.2, Mar 2024) |
Filament v3 (PHP 8.1+, Livewire 3) | Stable, but predates Filament v4 |
^3.x (3.0.0-alpha) |
Filament v4 — inferred, not documented | Alpha / pre-release |
The v3.0.0-alpha changelog contains a single line: a Dependabot bump of two JS dev dependencies. That tells you nothing about the PHP-side changes that forced a major version bump. Before adopting it, diff v2.1.2...v3.0.0-alpha on GitHub and read the composer.json and src/Forms/Components/MultiselectTwoSides.php in that tag yourself.
Because it's a pre-release, composer require won't grab it under default stability. Be explicit:
composer require lucasgiovanny/filament-multiselect-two-sides:^3.0@alpha
…and pin the exact tag (3.0.0-alpha) rather than floating ^3.0, since alphas move.
Optional publishes (only if you need to change wording or markup):
php artisan vendor:publish --tag="filament-multiselect-two-sides-translations"
php artisan vendor:publish --tag="filament-multiselect-two-sides-views"
Where to look first
composer.json in the release tag — the filament/filament constraint is the only reliable statement of panel compatibility. Release notes won't tell you.README.md — the fluent API surface (options(), searchable(), height(), labels, placeholders).src/Forms/Components/MultiselectTwoSides.php — the authoritative list of chainable methods for your installed version. Method names have shifted between releases; trust the source over blog posts, and re-read it after every major bump.resources/views/ — the Blade + Alpine component. This is where you go when the widget "doesn't update" or you need to style it.First use case: replace a painful multi-select
The natural first swap is a BelongsToMany field where Select::make('roles')->multiple() has become unusable because the option list is long and users can't see what they've picked.
use LucasGiovanny\FilamentMultiselectTwoSides\Forms\Components\MultiselectTwoSides;
MultiselectTwoSides::make('roles')
->label('Roles')
->relationship('roles', 'name') // sets options from the relation
->searchable()
->required();
The field's state is a plain array of keys — identical to a native multi-select — so $model->roles()->sync($data['roles']), policies, ->visible(), and wizard/repeater nesting all behave as you'd expect. That state-shape compatibility is the main reason to prefer this over a custom Livewire widget, and it's what makes the v2→v3 migration a form-definition exercise rather than a rewrite.
1. Plain option arrays (no relation)
MultiselectTwoSides::make('categories')
->options(Category::pluck('name', 'id')->toArray())
->default([]) // important: avoid a null initial state
->searchable()
->height(320)
->required();
Keep the array keyed by the value you want persisted. Passing a Collection of models works in some versions but is not guaranteed — pluck(...)->toArray() is the safe form.
2. Tenant-scoped / permission-filtered options
Options accept a closure, so scope them to the current user or tenant rather than shipping everything to the browser:
MultiselectTwoSides::make('members')
->options(fn () => User::query()
->where('tenant_id', filament()->getTenant()?->id)
->orderBy('name')
->pluck('name', 'id')
->toArray())
->searchable();
Because the closure is evaluated per render, it will re-run on Livewire updates — keep the query cheap (indexed column, select only what you need).
3. Two-sided editing of an existing relation
MultiselectTwoSides::make('roles')
->relationship('roles', 'name')
->searchable()
->required();
Do not combine relationship() with options(); the relationship resolves its own option set and passing both produces confusing duplicate/overridden behaviour. If you need filtered relation options, use ->relationship('roles', 'name', modifyQueryUsing: fn ($query) => $query->where('assignable', true)).
4. Making it reactive
The widget writes the full array back to the field state on every move, so dependent fields can react normally:
MultiselectTwoSides::make('roles')
->relationship('roles', 'name')
->live()
->afterStateUpdated(fn ($state, Set $set) => $set('notify_team', filled($state)));
If a dependent field doesn't refresh, add ->live(onBlur: true) or move the side effects into ->afterStateHydrated()/the form's ->save() — the internal Alpine state and the Livewire state occasionally need an extra round trip on slow connections.
5. Validation
Reuse standard Filament validation; the state is an array, so min/max rules count elements:
MultiselectTwoSides::make('roles')
->relationship('roles', 'name')
->required()
->rules(['array', 'min:1', 'max:5'])
->validationMessages(['min' => 'Pick at least one role.']);
For "at most N" with a friendly message, a closure rule is often clearer than max:.
6. Inside repeaters and wizards
It nests fine as long as every instance gets a bounded option set. For a repeater with 20 rows × 500 options you will feel the payload — consider a Select::make()->searchable() there instead, or lazy-load options per row.
7. Testing
fillForm() writes state directly, which is the right level for feature tests:
livewire(EditUser::class, ['record' => $user->id])
->fillForm(['roles' => [$roleA->id, $roleB->id]])
->call('save')
->assertHasNoFormErrors();
$this->assertTrue($user->fresh()->roles->contains($roleA));
This does not exercise the move buttons — treat the JS interaction as untested and smoke-test it manually after Filament upgrades. That manual pass matters more than usual on the 3.0.0-alpha line, where the Alpine/Blade layer is the most likely thing to have changed without a changelog entry.
8. Keeping the alpha upgrade cheap
Because v3 ships no migration notes, treat your form definitions as the contract. Centralise construction in a helper (app/Support/Forms/Multiselect.php) or a custom App\Forms\Components\TwoSides extends MultiselectTwoSides, so that when a method is renamed or an argument reorders you patch one file per panel rather than every resource.
9. Migrating a v2 form to the alpha
Branch, bump the constraint, then php artisan filament:cache-components and open every form that uses the component. The state shape should be unchanged, so any breakage will surface as a chainable method that no longer exists or a view that won't render — both visible immediately, not at save time. Run the feature tests for those resources before merging.
v3.0.0-alpha lists only a Dependabot bump of two JS dev dependencies. A major version bump with no feature or BC entries means the real changes are sitting in untagged commits — read the diff, not the release page. Do not infer "no breaking changes" from the empty notes.^2.x + Filament v3 until v3 stabilises, and pin the exact tag either way.MultiselectTwoSides::make(...)->view('vendor.filament-multiselect-two-sides.multiselect-two-sides') after publishing) so you can patch it yourself when the panel API moves.Select.#123 — Name), pre-format them in options().pluck('name','id') gives integer keys in PHP, but after a round trip you may be comparing "5" to 5. Use in_array($id, $state) (loose) or cast explicitly in afterStateUpdated; strict comparisons in custom logic are a common source of "it saved nothing" bugs.->default([]). Without it the first render can receive null, and the two columns render inconsistently or the move buttons throw. Same applies after ->state([]) resets.BelongsToMany. The array order in the widget looks meaningful but sync() discards it. If ordering matters, add a pivot order column and save it in the form's ->save()/afterCreate hook, or use an ordering package.SelectFilter::make()->multiple() or a TernaryFilter.content in tailwind.config.js, otherwise the two columns lose borders, spacing and dark-mode colours:
content: ['./vendor/lucasgiovanny/filament-multiselect-two-sides/resources/views/**/*.blade.php']
resources/views, your content glob silently matches nothing and the widget renders unstyled — the symptom looks like a CSS bug, not a package bug.disabled() is presentation-level. Verify it actually blocks the move actions in your version (the buttons are Alpine-driven); if not, pair it with ->dehydrated(false) or a server-side guard in mutateFormDataBeforeSave. Never rely on the widget for authorization — enforce with policies and a Rule::exists-style check on the submitted IDs.->view() let you add a "select all visible after search", a counter, or per-item badges without forking. That is the cheapest way to keep this package viable as Filament evolves — and the cheapest way to survive the gaps in the v3 release notes.How can I help you explore Laravel packages today?