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

Multi Select Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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.

Implementation Patterns

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.

Gotchas and Tips

  • jQuery must be global before the plugin loads. With Vite, 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.
  • A re-render silently kills the widget. Livewire, Turbo, and any 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.
  • Value types are strings. 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.
  • IDs must be unique per instance. The plugin derives element ids as ms-<originalId>. Duplicated ids (a foreach over form rows) give you a widget that controls the wrong select.
  • Tailwind Preflight will fight the CSS. 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.
  • Optgroups are not properly supported. If your data is naturally grouped (permissions by module), flatten with a prefixed label ("Users: view") or group with selectableHeader text instead.
  • Accessibility is weak. The panes are click-driven <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.
  • Unmaintained. No releases since ~2017, 0 Composer dependents, and no security surface (it's client-side only) — but also no fixes. Isolate it behind your Blade component so swapping to Tom Select later is a one-file change.
  • Testing. Dusk/Playwright must click the plugin's generated <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.
  • Extension points are thin. Beyond config flags and the events list, customisation means CSS overrides on .ms-container / .ms-selectable / .ms-selection, or monkey-patching the plugin. If you find yourself patching, that's the signal to migrate.
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.
coleus/frame
thtg88/journalism
filagora/filagora-ai-forums
rlacerda83/query-parser
socialdept/atp-orm
cboxdk/laravel-assistant-client
socialdept/atp-resolver
socialdept/tenant-domains
tautvydasr/blade-class-names
socialdept/color-scales
maherelgamil/controller
coleus/widgets
coleus/table
coleus/users
coleus/music
coleus/health
coleus/calendar
coleus/notes
coleus/support
coleus/settings