williamug/searchable-select
A feature-rich searchable dropdown component for Laravel Livewire 3 & 4, built with Alpine.js and Tailwind CSS. Supports real-time search, multi-select with tags, grouped options, dark mode, full keyboard accessibility, and cascading dependent dropdowns.
The package is a single Blade component (<x-searchable-select>) that replaces a standard <select> with a searchable, accessible dropdown. It is a UI-only component — it does not query your database, so your Livewire component owns the data and the package owns the rendering.
1. Install
composer require williamug/searchable-select
The service provider auto-registers. You do not need to publish anything to get started.
2. Wire the package views into Tailwind (do this before writing any markup)
This is the step that trips people up and it is the first thing to verify. The component's classes live in vendor/williamug/searchable-select/resources/views, which Tailwind does not scan by default. If you skip this, the dropdown renders unstyled or appears invisible (the panel may be hidden/absolute with no positioning).
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 (or keep npm run dev running).
3. First use case: a searchable foreign-key picker
This is the 80% case — you have a country_id column and 200+ countries, and a plain <select> is unusable.
// app/Livewire/ContactForm.php
class ContactForm extends Component
{
public $countries = []; // options source
public $country_id; // bound value
public function mount(): void
{
$this->countries = Country::orderBy('name')->get(['id', 'name']);
}
public function save(): void
{
$this->validate(['country_id' => 'required|exists:countries,id']);
}
}
<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">{{ $message }}</span> @enderror
Where to look first: the props table in the README is the whole API surface — 12 props, no config file, no facade. Read that table once and you know the package. Set option-value / option-label consciously; the defaults (id, name) will silently render blank rows if your columns differ.
options is passed as a collection into the component, which serialises it into the Livewire payload. A 20,000-row table will bloat every subsequent request. Select only the columns you need and filter server-side where possible:
$this->countries = Country::query()
->orderBy('name')
->limit(500)
->get(['id', 'name']); // never ->all() with all columns
id/name schemasoption-value and option-label are string keys resolved on each option, so they work identically for Eloquent models, plain arrays, and stdClass. Pick stable, human-readable label columns — name, not id, and definitely not a JSON blob.
<x-searchable-select
wire:model="country_code"
:options="$countries"
option-value="code"
option-label="country_name"
/>
multiple requires the bound property to be an array. Always initialise it (public $selected_skills = [];) — null is a common source of "nothing happens" bugs on first selection.
<x-searchable-select
wire:model="selected_skills"
:options="$skills"
:multiple="true"
/>
Validation for arrays is the standard Laravel form:
$this->validate([
'selected_skills' => 'array|min:1|max:5',
'selected_skills.*' => 'exists:skills,id',
]);
updated* hooksThe component uses $wire.entangle() internally, so a selection triggers a normal Livewire update and your updated{PropertyName}() hook fires. There is no custom event to listen for — this is the idiomatic integration:
public function updatedCountryId($value): void
{
$this->regions = $value
? Region::where('country_id', $value)->orderBy('name')->get()
: [];
// Reset children — the component will render nothing selected,
// but your PHP state must be cleared too or stale IDs persist on save.
$this->region_id = null;
$this->city_id = null;
$this->cities = [];
}
<x-searchable-select
wire:model="region_id"
:options="$regions"
:placeholder="empty($regions) ? 'Select a country first' : 'Select region'"
:disabled="!$country_id"
/>
Note the hook name: updatedCountryId, not updatedCountry_id (Livewire converts country_id → CountryId). Getting this wrong is silent — no error, no reload.
Only use grouped when you can genuinely organise the data; the shape is [{label, options: [...]}] and both keys are configurable:
public $locations = [
['label' => 'North America', 'options' => [['id' => 1, 'name' => 'United States'], ...]],
['label' => 'Europe', 'options' => [['id' => 4, 'name' => 'United Kingdom'], ...]],
];
<x-searchable-select
wire:model="country_id"
:options="$locations"
:grouped="true"
group-label="label"
group-options="options"
/>
The same option-value / option-label props still apply to the inner options.
Because it is a plain Blade component, it composes with wire:model on Livewire\Form objects and model attributes exactly like any input:
<x-searchable-select wire:model="form.country_id" :options="$countries" />
For a filter bar, bind to a query-string property (#[Url] public $category_id;) and let the entangle do the rest.
Tailwind purging is the #1 failure mode. If the dropdown looks unstyled, is invisible, or the panel renders in the wrong place, 95% of the time the @source / content entry is missing or the assets weren't rebuilt after composer require. Check the built CSS actually contains something like .absolute from the package path. In CI, remember npm run build must run after composer install.
Do not pass selected-value. The README is explicit: wire:model is the only binding mechanism, backed by entangle. Adding a manual :selected-value will conflict with it. Likewise there is no wire:model.live vs .blur distinction to manage — the entangle pushes on every pick.
Multi-select properties must be arrays, and PHP state must be reset deliberately. The component won't clear region_id for you when a parent changes. Every cascading reset is your responsibility, in both directions (dropdown UI and PHP property). Test the "select parent, pick child, change parent, save" path — that's where stale IDs leak into the database.
disabled is visual + interactive, not validation. It prevents clicking, but a crafted request can still submit the property. Always validate with exists:/in: rules server-side — the component is not a security boundary.
Watch the Livewire payload. :options is serialised each render. If a page feels sluggish, check the Livewire payload size in the network tab; the fix is almost always a select(...) with fewer columns or a lower limit.
Customisation goes through publishing, and then it's yours.
php artisan vendor:publish --tag=searchable-select-views
After publishing, package updates will not reach your overridden views. Prefer wrapping the component in your own Blade component (<x-ui.select>) that forwards props, rather than editing the vendor view — you keep upgradeability and get one place to change styling app-wide.
Compatibility is narrow-by-design. PHP 8.2+, Laravel 11/12/13, Livewire 3 or 4, Tailwind 3 or 4. Nothing surprising, but verify your Livewire major before debugging anything else — Alpine is bundled with Livewire, so an Alpine version mismatch from a separate Alpine install is a realistic culprit for "panel won't open".
Maturity caveat: pin it and test the upgrade. This is a 2-star, low-download package (dependents: 0) with a single maintainer. That is not disqualifying — it's a small, dependency-free, MIT component you could vendor in an afternoon — but take the standard precautions: commit the exact version in composer.lock, run your test suite after bumps, and be prepared to fork. If the dropdown is load-bearing for a critical flow, publish the views so a breaking change or abandonment doesn't block you.
Debugging checklist, in order: (1) is the built CSS current and does it contain the package classes? (2) is wire:model pointing at a real public property with the right type (array for multiple)? (3) do option-value/option-label keys actually exist on the data? (4) is the updatedPropertyName hook cased correctly? (5) is another Alpine component on the page swallowing the click (nesting inside a modal or an existing x-data scope)? Nine times out of ten it's step 1 or 3.
Testing: the component is client-side Alpine, so browser tests (Laravel Dusk / Pest browser plugin) are required to exercise open/search/select. For the rest, unit-test your Livewire component's updated* hooks and validation rules directly with Livewire::test() — that covers the logic that actually breaks, and skips the styling concerns entirely.
How can I help you explore Laravel packages today?