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 Multiselect Two Sides Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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

  1. composer.json in the release tag — the filament/filament constraint is the only reliable statement of panel compatibility. Release notes won't tell you.
  2. README.md — the fluent API surface (options(), searchable(), height(), labels, placeholders).
  3. 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.
  4. 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.

Implementation Patterns

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.

Gotchas and Tips

  • The alpha is undocumented, and its changelog is a false signal. 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.
  • Alpha means alpha. No stable v3, no upgrade guide, no reported downstream usage (the package still has a small star count and effectively no dependents). Fine to trial on a branch against Filament v4; keep production on ^2.x + Filament v3 until v3 stabilises, and pin the exact tag either way.
  • Version/maintenance risk remains the headline gotcha. The v2 line's last stable release was 2024-03-13 and predates Filament v4. Whichever track you choose, plan to vendor the Blade view (MultiselectTwoSides::make(...)->view('vendor.filament-multiselect-two-sides.multiselect-two-sides') after publishing) so you can patch it yourself when the panel API moves.
  • All options are rendered into the DOM. The search box filters client-side over the options you passed in. With several thousand options you get a large HTML payload on every Livewire round trip and a visibly sluggish widget. Scope options by tenant/permission, or fall back to a searchable Select.
  • Search matches labels, not values. Users cannot find an option by ID. If labels are non-unique or encoded (#123 — Name), pre-format them in options().
  • Hydration casts keys to strings. 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.
  • Always set ->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.
  • Order is not persisted by 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.
  • Don't use it as a table filter. This is a form component only; for table filters stick with SelectFilter::make()->multiple() or a TernaryFilter.
  • Custom Filament themes need the view path registered. If you compile your own panel theme with Tailwind, add the package views to 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']
    
  • Re-check the theme path after a major bump. If v3 renames or restructures resources/views, your content glob silently matches nothing and the widget renders unstyled — the symptom looks like a CSS bug, not a package bug.
  • Translation keys drive the default labels/buttons. Publish translations before overriding strings in Blade; editing the published view for a wording change is a maintenance trap.
  • 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.
  • Extension point: published views + ->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.
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.
ignibyte/aic-framework
glavweb/data-schema-bundle
glavweb/datagrid-bundle
tacticmedia/scim-server
uni-method/frame
neika-ai/sdk
adachsoft/php-code-style
pharaonic/laravel-notification-dependencies
beautinow/translug
dev-brainiac/bot-cloaked
octoberrain/html
octoberrain/extension
octoberrain/exception
octoberrain/contracts
adachsoft/api-version-bundle
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox