williamug/searchable-select
Livewire Searchable Select is a feature-rich searchable dropdown component for Laravel Livewire 3 and 4, built with Alpine.js and Tailwind CSS. It includes real-time search, multi-select tags, grouped options, clear buttons, dark mode, and accessible keyboard navigation.
1. Install and satisfy the real prerequisite
composer require williamug/searchable-select
The service provider auto-registers; there is nothing to configure. But the single biggest cause of "I installed it and nothing renders" is Tailwind purging the package views. Do this before you write any markup, because the failure mode (unstyled, or an invisible/wrongly-positioned panel) looks like a component bug rather than a CSS one.
Tailwind v4 — in resources/css/app.css:
@import 'tailwindcss';
@source '../../vendor/williamug/searchable-select/resources/views/**/*.blade.php';
Tailwind v3 — in tailwind.config.js:
content: [
'./resources/**/*.blade.php',
'./vendor/williamug/searchable-select/resources/views/**/*.blade.php',
],
Then npm run build. Remember to re-run this after composer update in CI, since the vendor path is scanned at build time.
2. Look first at the anonymous component, not the class
There is no PHP component class you interact with — this is a Blade anonymous component (<x-searchable-select />) plus Alpine for the behavior, talking back to Livewire through $wire.entangle(). So the things worth reading are:
resources/views/ — the Blade source (publish it with php artisan vendor:publish --tag=searchable-select-views if you plan to override).3. First real use case: a single searchable select bound to wire:model
class ContactForm extends Component
{
public $countries;
public $country_id;
public function mount()
{
$this->countries = Country::orderBy('name')->get();
}
}
<x-searchable-select
wire:model="country_id"
:options="$countries"
placeholder="Select a country"
search-placeholder="Search countries..."
/>
@error('country_id') <span class="text-red-500 text-sm">{{ $message }}</span> @enderror
Note the one thing that surprises people: there is no :selected-value prop. Binding is handled entirely by wire:model + entanglement, so if you copy an example from a similar package that passes a selected value explicitly, you'll fight it. Just set $country_id in mount() and the component hydrates.
Map your columns explicitly. The defaults are id / name. Anything else (codes, slugs, translated names) needs the mapping props, and it's cleaner to set them once per model than to reshape arrays in every component:
<x-searchable-select
wire:model="country_code"
:options="$countries"
option-value="code"
option-label="country_name"
/>
Multi-select: the bound property must be an array. Declare public $selected_skills = []; and pass :multiple="true". If you leave it as null, you get type friction on first render. This is the pattern for tags/pivot syncing:
public $selected_skills = [];
public function save()
{
$this->validate([
'selected_skills' => 'array',
'selected_skills.*' => 'exists:skills,id',
]);
$this->user->skills()->sync($this->selected_skills);
}
Cascading dropdowns are the highest-value pattern and they work by leaning on Livewire's updated* lifecycle hooks — the component calls $wire.set() internally, so your hooks fire normally:
public function updatedCountryId($value)
{
$this->regions = Region::where('country_id', $value)->orderBy('name')->get();
$this->region_id = null;
$this->city_id = null;
$this->cities = [];
}
<x-searchable-select
wire:model="region_id"
:options="$regions"
:placeholder="empty($regions) ? 'First select a country' : 'Select Region'"
:disabled="!$country_id"
/>
Two habits worth adopting here: always reset child selections in the parent's updated* hook (otherwise you persist an inconsistent region_id under a new country_id), and always disable the child with :disabled rather than relying on empty options — it communicates intent and prevents a stray click.
Grouped options — match the shape exactly. The component expects a list of groups, each with a label key and an options key (label/options by default, overridable via group-label / group-options):
public $locations = [
['label' => 'North America', 'options' => [['id' => 1, 'name' => 'United States'], /* ... */]],
['label' => 'Europe', 'options' => [['id' => 4, 'name' => 'United Kingdom'], /* ... */]],
];
<x-searchable-select wire:model="location_id" :options="$locations" :grouped="true" />
If grouped mode renders nothing, it's almost always a shape mismatch — a flat array passed with :grouped="true", or a group missing its options key.
Keep option payloads small on real pages. Search is client-side, so every option is serialized into the Livewire payload and rendered into the DOM. For a country list that's fine; for 20k rows it isn't. The practical workaround is to select only what you render and cap the list:
$this->countries = Country::query()->orderBy('name')->get(['id', 'name']);
Testing with Livewire's helpers works normally because the value is just a public property:
Livewire::test(ContactForm::class)
->set('country_id', 1)
->call('save')
->assertHasNoErrors();
The unstyled/invisible dropdown is a Tailwind problem, 9 times out of 10. Before you open an issue, verify the package view path is in @source (v4) or content (v3) and that you rebuilt assets. In a Docker/CI build where composer install --no-dev ordering differs, the vendor directory may not exist at build time — install PHP deps before npm run build.
Search is client-side only. There is no server-side query, no debounce-to-endpoint, no pagination. For big lookup tables (users, products, addresses) this package is the wrong tool as-is; either pre-filter the option list server-side based on another input, or wrap it and accept a bounded list. Don't assume a searchable attribute exists to toggle remote search.
Entanglement means updated* hooks fire on every selection — including in multi-select, where each tag add/remove triggers a round trip. That's the correct behavior for cascading, but keep the updated* hook cheap (no N+1 queries, no expensive recomputation).
Value types are whatever you put in option-value. Eloquent will hand you ints; if you derive options from a pluck() or an API payload you may end up with strings, and exists:countries,id validation plus sync() will behave differently. Normalize in the component (->map(fn ($c) => [...]) or cast) rather than debugging it later.
clearable defaults to true. On a required field that means the user can null out a value you marked required — that's fine, validation catches it, but make sure you render @error next to the field or the failure looks silent.
Publishing views is a fork, not a config. Once you vendor:publish --tag=searchable-select-views, your copy wins forever. Bug fixes and markup changes in new releases won't reach you. Prefer overriding with surrounding Tailwind classes first; only publish if you truly need structural changes, and diff against the vendor file on each upgrade.
Weigh maturity before betting on it. This is a young package — two stars, zero dependents, a single maintainer, and a very recent first release. That doesn't make it bad (the API is clean and MIT lets you vendor or fork it), but it does mean: pin the version in composer.json, read the changelog before minor bumps, and add a smoke test in your suite that renders the component and asserts the bound property updates — that way a future breaking release fails your CI instead of your users.
Accessibility and dark mode are advertised — keyboard navigation, ARIA attributes, theme adaptation. Verify keyboard flow (Tab in, arrows, Enter, Escape, Backspace-to-remove-tag in multi-select) inside your own layout, since z-index and overflow-hidden on parent containers are the usual things that break a floating panel. If the dropdown is clipped, check ancestors for overflow-hidden before assuming a component bug.
How can I help you explore Laravel packages today?