components/multi-select
A flexible multi-select component for Laravel apps with searchable options, tag input, keyboard navigation, validation, and Tailwind styling. Drop it into Blade or Livewire forms for a fast, accessible selection experience. Clean, responsive, and built for Laravel developers.
Treat components/multi-select as a front-end asset, not a Composer package. It is the Lou MultiSelect jQuery plugin (lou/multi-select, MIT), so it lives in resources/js, is loaded through Vite, and is driven from Blade — Laravel only ever sees the <select multiple name="roles[]"> that the plugin decorates.
Install and register it:
npm install jquery multi-select
// resources/js/bootstrap.js
import $ from 'jquery';
window.$ = window.jQuery = $;
import 'multi-select';
import 'multi-select/css/multi-select.css'; // or import into app.css
If you prefer not to bundle it, the CDN path is fine for a quick internal screen, but pin the version — the plugin has been effectively unmaintained since 2017.
Minimal markup — the plugin only needs a multiple select with a stable id:
<select id="roles" name="roles[]" multiple>
@foreach ($roles as $role)
<option value="{{ $role->id }}"
@selected($user->roles->contains($role->id))>
{{ $role->name }}
</option>
@endforeach
</select>
@push('scripts')
<script>
document.addEventListener('DOMContentLoaded', () => {
$('#roles').multiSelect({
selectableHeader: '<span>Available</span>',
selectionHeader: '<span>Assigned</span>',
enableSearch: true,
searchPlaceholder: 'Filter roles…',
});
});
</script>
@endpush
Look first at: the 20-option config table in the README (every behaviour is a flag), then jquery.multi-select.js — it's a single readable file, and the public API (select, deselect, refresh, destroy) is documented inline. The demo site loudev.com is the fastest way to sanity-check an option before wiring it up.
First real use case: the role/permission picker on a user edit screen. It's the exact shape this plugin was designed for — a few dozen options, two-pane layout, searchable, and the browser still submits a normal array on form POST. If your option list comes from an Eloquent table with < 200 rows, this is a ten-minute integration.
Blade component wrapper. Wrap the plugin once so you don't repeat init code and so option/selection rendering stays consistent:
{{-- resources/views/components/multi-select.blade.php --}}
@props(['name', 'options', 'selected' => [], 'placeholder' => null])
<select id="{{ $id = 'ms-'.\Illuminate\Support\Str::slug($name).'-'.\Illuminate\Support\Str::random(4) }}"
name="{{ $name }}[]" multiple {{ $attributes }}>
@foreach ($options as $value => $label)
<option value="{{ $value }}" @selected(in_array($value, (array) $selected))>{{ $label }}</option>
@endforeach
</select>
@push('scripts')
<script>
document.addEventListener('DOMContentLoaded', () => {
$('#{{ $id }}').multiSelect(@json($config ?? []));
});
</script>
@endpush
The @selected attribute is what the plugin reads on init — you do not call .multiSelect('select', ...) to preselect. Forget @selected and you get an empty right-hand pane even though the DB says otherwise.
Validation and persistence. From Laravel's perspective this is an ordinary array input, so keep the rules boring:
$data = $request->validate([
'roles' => ['array'],
'roles.*' => ['integer', 'exists:roles,id'],
]);
$user->roles()->sync($data['roles'] ?? []);
roles must be nullable/array and the input must be name="roles[]" — if you omit the brackets the plugin still works but PHP receives only the last value, which is a classic silent-data-loss bug.
Repopulating after a validation error. Use old() with a fallback to the model, since a failed request must show the user's attempt, not the persisted state:
<x-multi-select
name="roles"
:options="$roles->pluck('name', 'id')"
:selected="old('roles', $user->roles->pluck('id')->all())" />
Programmatic control. The API is string-id based, so cast your keys:
const ms = $('#roles');
ms.multiSelect('select', ['2', '5']); // ids as strings
ms.multiSelect('deselect_all');
ms.multiSelect('refresh'); // after mutating <option>s
Reacting to changes. The plugin fires namespaced events; use them to sync derived UI (counters, permission summaries, dependent selects):
$('#roles').on('ms-select ms-deselect', function () {
const count = $(this).val()?.length ?? 0;
$('#role-count').text(`${count} selected`);
});
Livewire / Alpine integration. This is where most of the day-to-day work is. The plugin hides the real <select> and renders <ul>/<li> panes, so Livewire's wire:model can drift out of sync. Two workable patterns:
{{-- Pattern A: let the plugin own the DOM, push state manually --}}
<div wire:ignore>
<select id="roles" multiple>…</select>
</div>
<script>
$('#roles')
.multiSelect({ /* … */ })
.on('change', () => $wire.set('roles', $('#roles').val() ?? []));
</script>
{{-- Pattern B: Alpine owns re-init, plugin owns interaction --}}
<div x-data x-init="$nextTick(() => $('#roles').multiSelect({...}))">
wire:ignore is the load-bearing part: a Livewire re-render replaces the <select> and leaves the plugin's generated panes orphaned, producing a duplicated, dead widget. If you must re-render, tear down first — $('#roles').multiSelect('destroy') before the morph, re-init after.
Large lists. For anything above a few hundred options, don't ship them all: render only the currently selected ones server-side, and hydrate the "available" side from a search endpoint using refresh after appending options. Otherwise the plugin builds thousands of <li> nodes on init and the page stalls.
import 'multi-select' in the same file as window.$ = $ is not enough — module evaluation order can hoist the import. Put the jQuery assignment in bootstrap.js, import bootstrap first in app.js, and only then import the plugin. Symptom: $(...).multiSelect is not a function.innerHTML replacement all invalidate the plugin. Guard with wire:ignore / data-turbo-permanent, or destroy-then-init around the update.refresh is required after mutating options. Adding or removing <option> elements programmatically does not update the two panes. Call .multiSelect('refresh') (and re-apply selection state) every time.multiSelect('select', [1, 2]) will not match options with value="1". Cast with ->map(fn ($v) => (string) $v) when passing ids from PHP or JS state.destroy before removing the field, especially in modals or repeatable form rows — otherwise the generated wrappers and event handlers leak and stack up on the next open.ms-<originalId>. Duplicated ids (a foreach over form rows) give you a widget that controls the wrong select.list-style: none on <ul>, unset border/image styles on the search input, and the ms-container widths all need overrides. Budget a short SCSS pass to match your design system rather than shipping the stock skin."Users: view") or group with selectableHeader text instead.<li> elements with limited keyboard and screen-reader semantics. Acceptable for internal admin panels; for customer-facing forms, prefer Tom Select or Choices.js, which are actively maintained and keyboard-navigable.<li> nodes, not selectOption() on the hidden original — the latter bypasses the plugin and won't validate that the UI actually works. Assert on the submitted payload instead of .val() where you can..ms-container / .ms-selectable / .ms-selection, or monkey-patching the plugin. If you find yourself patching, that's the signal to migrate.How can I help you explore Laravel packages today?