bolyfci/livewire-multiselect
A Livewire multiselect component for Laravel that provides searchable dropdowns, tag-style selected items, and straightforward Blade integration, making it simple to build responsive multi-choice forms with server-side state and validation without custom JavaScript.
Install and inspect before integrating.
composer require bolyfci/livewire-multiselect
php artisan vendor:publish --provider="Bolyfci\LivewireMultiselect\LivewireMultiselectServiceProvider"
php artisan livewire:list # confirm the registered component name
Before writing a single line of app code, open vendor/bolyfci/livewire-multiselect/src/ and read the component class. Because this package predates Livewire 3, verify three things immediately:
Livewire\Component namespace it extends (Livewire\Component vs Livewire\Volt/v3 conventions).Livewire::component(...) or a Blade component tag.vendor:publish tag or injected through @livewireScripts.First use case — a tag picker on an edit form. The canonical shape is an options array plus a bound wire:model:
{{-- resources/views/posts/edit.blade.php --}}
<form wire:submit="save">
<livewire:multiselect
wire:model="post.tags"
:options="$allTags" {{-- ['1' => 'Laravel', '2' => 'PHP'] --}}
placeholder="Pick tags…"
/>
@error('post.tags') <span class="text-red-600">{{ $message }}</span> @enderror
</form>
// app/Livewire/EditPost.php
public array $post = ['tags' => []];
public function mount(Post $post): void
{
$this->post['tags'] = $post->tags()->pluck('id')->map(fn ($id) => (string) $id)->all();
}
public function save(): void
{
$this->validate(['post.tags' => ['array'], 'post.tags.*' => ['integer', 'exists:tags,id']]);
$this->post->tags()->sync($this->post['tags']);
}
If the component name or option keys differ from the above, the README in the repo is short enough to read end-to-end in two minutes — do that, then throw the example above away.
Bind the array, not a collection. The component round-trips a flat array of selected keys. Normalise at the boundaries: cast to strings on mount() (see above) and cast back to integers before sync()/whereIn(). Treating the bound property as a Collection will break hydration on the next request.
Defer the wire:model in long forms. Each toggle is otherwise a full Livewire round trip:
<livewire:multiselect wire:model.defer="selectedTags" :options="$options" />
Use plain wire:model only when the selection must drive reactive UI (dependent dropdowns, live previews, conditional validation messages).
Options are a map, so build them deliberately. Pass key => label pairs from an Eloquent query rather than a raw model collection:
public function getOptionsProperty(): array
{
return Tag::orderBy('name')->pluck('name', 'id')->all();
}
This keeps keys stable and survives Livewire re-renders. If you need per-option metadata (color, avatar, disabled state), the package almost certainly cannot render it — pass labels only and render the richer display server-side after selection.
Multiple instances on one page require wire:key. Any multiselect rendered inside a loop must carry a stable key, or Livewire will reuse the first component's JS state across siblings:
@foreach ($members as $member)
<livewire:multiselect
wire:key="member-tags-{{ $member->id }}"
wire:model="memberTags.{{ $member->id }}"
:options="$allTags"
/>
@endforeach
Pre-selection from relationships — pluck before passing, and never pass a lazy-loading relation straight through:
$selected = $post->tags()->pluck('id')->all();
Form reset. After a successful save(), reset the bound property explicitly ($this->post['tags'] = []) or call $this->reset('post'). If the JS widget caches its own rendered pills, a matching $refresh on the parent is the reliable fix.
Integration inside modals and tabs. If your modal/tab content is loaded lazily, initialise nothing manually — but do verify assets are loaded in the layout before the component mounts. A multiselect that renders as an empty <select> is nearly always a JS asset-ordering problem, not a Livewire problem.
Event vs. binding. If the component emits a selection event in addition to wire:model, prefer the binding for persistence and use the event only for side effects ($dispatch('tags-updated', count: n) for a badge counter). Dual sources of truth are the fastest way to get drift.
Livewire 3 compatibility is the headline risk. Last release February 2021, zero dependents. It was written against Livewire 2 ($listeners, wire:model.defer, @livewireStyles). Expect at minimum: property names colliding with reserved v3 names, $emit semantics, and asset injection via @livewireScripts behaving differently. Budget an hour to fork and patch, or test it on a throwaway branch before it reaches a real form.
Deferred binding hides the stale-selection bug. With wire:model.defer, $this->post['tags'] inside a updated() hook or a computed property is not updated until the next request. If you need to react to each selection, drop the .defer.
Key type coercion will bite you. Options keys are strings in HTML but integers in your DB. sync(['1','2']) works; array_diff($selected, $tagIds) with mixed types silently returns everything. Cast at the boundary — one helper method, used everywhere.
Empty state is ambiguous. Confirm whether clearing all selections leaves [], null, or ''. A null blowing up a typed public array $tags produces a TypeError on the next hydrate, and the stack trace points at the framework, not the widget.
JS/CSS publishing is separate from the component. The PHP side renders fine without assets — you get a plain multi-<select> and no error. When styling looks wrong, check in this order: published assets present in public/vendor/..., layout includes them after the Livewire scripts, then browser console for a duplicate-init warning.
Alpine.js collisions. If the widget bundles its own Alpine or Vue-style listener on the root element, an outer x-data can swallow events. Keep the multiselect root free of x-on handlers; attach handlers to a wrapping <div> instead.
Debugging. From the browser console, $wire.get('post.tags') confirms what the server will receive — faster than dumping in the component. On the PHP side, dump() inside the component's updated hook (or a updatedPostTags() method on the parent) tells you whether the value is arriving at all.
Extension points, in order of preference:
App\Livewire\TagPicker that composes the package component and exposes a clean API (options, selected, max). This isolates the vendor call to one file when you eventually replace it.Pin the version and write the exit plan now. composer require bolyfci/livewire-multiselect:^1.0 with a committed composer.lock, plus a note in the PR. If this component is load-bearing in more than one form, a 30-line Alpine + wire:model tag input (or Tom Select wrapped the same way) is a well-trodden replacement path with no abandoned dependency — worth considering before the second integration, not after the fifth.
How can I help you explore Laravel packages today?