williamug/searchable-select
Feature-rich searchable select for Laravel Livewire 3/4, powered by Alpine.js and styled with Tailwind. Supports single and multi-select, grouped options, cascading dropdowns, clear button, dark mode, accessibility, and real-time client-side search.
A powerful, feature-rich searchable dropdown component for Laravel Livewire 3 & 4 applications. Built with Alpine.js and styled with Tailwind CSS.
Install the package via Composer:
composer require williamug/searchable-select
The package will automatically register its service provider. You're ready to use it immediately!
You can publish the view files if you need to customize the component HTML:
php artisan vendor:publish --tag=searchable-select-views
The component is styled with Tailwind utility classes. You must tell Tailwind to scan the package's blade views, otherwise those classes will be purged and the dropdown will appear unstyled or invisible.
@tailwindcss/vite or @tailwindcss/postcss)Add a @source directive to your main CSS file (typically resources/css/app.css):
@import 'tailwindcss';
@source '../../vendor/williamug/searchable-select/resources/views/**/*.blade.php';
Then rebuild your assets:
npm run build
tailwindcss with tailwind.config.js)Add the package views to the content array in tailwind.config.js:
export default {
content: [
'./resources/**/*.blade.php',
'./resources/**/*.js',
'./vendor/williamug/searchable-select/resources/views/**/*.blade.php',
],
theme: {
extend: {},
},
plugins: [],
}
Then rebuild your assets:
npm run build
That's it! The component will use Tailwind classes and support dark mode automatically.
Step 1: Create a Livewire Component
php artisan make:livewire ContactForm
Step 2: Set up your component class
<?php
namespace App\Livewire;
use App\Models\Country;
use Livewire\Component;
class ContactForm extends Component
{
public $countries;
public $country_id;
public function mount()
{
// Load all countries
$this->countries = Country::orderBy('name')->get();
}
public function save()
{
$this->validate([
'country_id' => 'required|exists:countries,id',
]);
// Save your data...
}
public function render()
{
return view('livewire.contact-form');
}
}
Step 3: Use the component in your Blade view
<div>
<label for="country" class="block mb-2">Country</label>
<x-searchable-select
wire:model="country_id"
:options="$countries"
placeholder="Select a country"
search-placeholder="Type to search countries..."
/>
@error('country_id')
<span class="text-red-500 text-sm mt-1">{{ $message }}</span>
@enderror
<button wire:click="save" class="mt-4">Save</button>
</div>
That's it! You now have a fully functional searchable dropdown. The component automatically syncs with your Livewire property via wire:model — no extra :selected-value prop needed.
Comprehensive list of all available props:
| Prop | Type | Default | Required | Description |
|---|---|---|---|---|
options |
Array/Collection | [] |
Yes | The list of options to display in the dropdown |
placeholder |
String | 'Select option' |
No | Placeholder text shown when nothing is selected |
searchPlaceholder |
String | 'Search...' |
No | Placeholder for the search input field |
disabled |
Boolean | false |
No | Whether the dropdown is disabled |
emptyMessage |
String | 'No options available' |
No | Message shown when the options array is empty |
optionValue |
String | 'id' |
No | The key/property to use as the option value |
optionLabel |
String | 'name' |
No | The key/property to use as the option display label |
multiple |
Boolean | false |
No | Enable multi-select mode (allows selecting multiple options) |
clearable |
Boolean | true |
No | Show/hide the clear button |
grouped |
Boolean | false |
No | Enable grouped/categorized options mode |
groupLabel |
String | 'label' |
No | Key for group labels (when grouped is true) |
groupOptions |
String | 'options' |
No | Key for group options array (when grouped is true) |
wire:modelis a standard Livewire directive, not a declared prop. Pass it aswire:model="propertyName"and the component handles two-way binding automatically.
options: The data source for your dropdown. Can be:
Country::all()[['id' => 1, 'name' => 'USA'], ...]wire:model: The Livewire property to bind to. The component uses $wire.entangle() internally to keep the selected value in sync automatically.
placeholder: Shows when no option is selectedsearchPlaceholder: Shows in the search inputemptyMessage: Shows when options array is emptyoptionValue: Which property to use as the value (saved to wire:model)optionLabel: Which property to display to usersExample:
// If your model has 'code' and 'country_name' fields
$countries = Country::all(); // [['code' => 'US', 'country_name' => 'United States'], ...]
<x-searchable-select
wire:model="country_code"
:options="$countries"
option-value="code"
option-label="country_name"
/>
multiple: Enables multi-select mode with visual tagsclearable: Shows/hides the × button to clear selectiondisabled: Grays out the component and prevents interactiongrouped: Enables category headers in the dropdownThe most common use case - a simple searchable dropdown:
<?php
namespace App\Livewire;
use App\Models\Country;
use Livewire\Component;
class UserProfile extends Component
{
public $countries;
public $country_id;
public function mount()
{
$this->countries = Country::orderBy('name')->get();
}
public function render()
{
return view('livewire.user-profile');
}
}
<x-searchable-select
wire:model="country_id"
:options="$countries"
placeholder="Select your country"
search-placeholder="Search countries..."
/>
Select multiple options with visual tags/badges:
<?php
namespace App\Livewire;
use App\Models\Skill;
use Livewire\Component;
class UserSkills extends Component
{
public $skills;
public $selected_skills = []; // Array to hold multiple selections
public function mount()
{
$this->skills = Skill::orderBy('name')->get();
}
public function render()
{
return view('livewire.user-skills');
}
}
<x-searchable-select
wire:model="selected_skills"
:options="$skills"
:multiple="true"
placeholder="Select your skills"
search-placeholder="Search skills..."
/>
{{-- Display selected skills --}}
@if(!empty($selected_skills))
<div class="mt-2">
<p>Selected: {{ count($selected_skills) }} skills</p>
</div>
@endif
Selected items show as blue badges with × remove buttons.
Create related dropdowns where child options depend on parent selections (e.g., Country → Region → City):
<?php
namespace App\Livewire;
use App\Models\{Country, Region, City};
use Livewire\Component;
class LocationSelector extends Component
{
// Options
public $countries;
public $regions = [];
public $cities = [];
// Selected values
public $country_id;
public $region_id;
public $city_id;
public function mount()
{
// Load countries on page load
$this->countries = Country::orderBy('name')->get();
}
public function updatedCountryId($value)
{
// When country changes, load its regions
$this->regions = Region::where('country_id', $value)
->orderBy('name')
->get();
// Reset child selections
$this->region_id = null;
$this->city_id = null;
$this->cities = [];
}
public function updatedRegionId($value)
{
// When region changes, load its cities
$this->cities = City::where('region_id', $value)
->orderBy('name')
->get();
// Reset city selection
$this->city_id = null;
}
public function render()
{
return view('livewire.location-selector');
}
}
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<!-- Country Dropdown -->
<div>
<label class="block mb-2 font-medium">Country</label>
<x-searchable-select
wire:model="country_id"
:options="$countries"
placeholder="Select Country"
search-placeholder="Search countries..."
/>
</div>
<!-- Region Dropdown (disabled until country is selected) -->
<div>
<label class="block mb-2 font-medium">Region</label>
<x-searchable-select
wire:model="region_id"
:options="$regions"
:placeholder="empty($regions) ? 'First select a country' : 'Select Region'"
:disabled="!$country_id"
/>
</div>
<!-- City Dropdown (disabled until region is selected) -->
<div>
<label class="block mb-2 font-medium">City</label>
<x-searchable-select
wire:model="city_id"
:options="$cities"
:placeholder="empty($cities) ? 'First select a region' : 'Select City'"
:disabled="!$region_id"
/>
</div>
</div>
Key points:
updatedPropertyName() methods in your Livewire component to react to changes — $wire.set() inside the component triggers these automatically on every selection:disabled prop to prevent selecting child before parentOrganize options into labeled categories:
<?php
namespace App\Livewire;
use Livewire\Component;
class CountrySelector extends Component
{
public $country_id;
public $locations = [
[
'label' => 'North America',
'options' => [
['id' => 1, 'name' => 'United States'],
['id' => 2, 'name' => 'Canada'],
['id' => 3, 'name' => 'Mexico'],
]
],
[
'label' => 'Europe',
'options' => [
['id' => 4, 'name' => 'United Kingdom'],
['id' => 5, 'name' => 'France'],
['id' => 6, 'name' => 'Germany'],
['id' => 7, 'name' => 'Spain'],
['id' => 8, 'name' => 'Italy'],
]
],
[
'label' => 'Asia',
'options' => [
['id' => 9, 'name' => 'Japan'],
['id' => 10, 'name' => 'China'],
['id' => 11, 'name' => 'India'],
['id' => 12, 'name' => 'South Korea'],
]
],
];
public function render()
{
return view('livewire.country-selector');
}
}
<x-searchable-select
wire:model="country_id"
:options="$locations"
:grouped="true"
placeholder="Select a country"
search-placeholder="Search countries..."
/>
Custom group keys:
If your data structure uses different keys:
public $categories = [
[
'category_name' => 'Fruits', // Custom group label key
'items' => [ // Custom options key
['code' => 'APL', 'title' => 'Apple'],
['code' => 'BAN', 'title' => 'Banana'],
]
],
];
<x-searchable-select
wire:model="selected_item"
:options="$categories"
:grouped="true"
group-label="category_name"
group-options="items"
option-value="code"
option-label="title"
/>
When your data uses different property names:
public $products = [
['sku' => 'PROD-001', 'product_name' => 'Laptop'],
['sku' => 'PROD-002', 'product_name' => 'Mouse'],
['sku' => 'PROD-003', 'product_name' => 'Keyboard'],
];
public $selected_sku;
<x-searchable-select
wire:model="selected_sku"
:options="$products"
option-value="sku"
option-label="product_name"
placeholder="Select a product"
/>
Integrate with Laravel's validation:
<?php
namespace App\Livewire;
use App\Models\Country;
use Livewire\Component;
class ContactForm extends Component
{
public $countries;
public $country_id;
public $city_id;
protected $rules = [
'country_id' => 'required|exists:countries,id',
'city_id' => 'required|exists:cities,id',
];
protected $messages = [
'country_id.required' => 'Please select a country.',
'city_id.required' => 'Please select a city.',
];
public function mount()
{
$this->countries = Country::all();
}
public function save()
{
$validated = $this->validate();
// Use validated data...
}
public function render()
{
return view('livewire.contact-form');
}
}
<div>
<label>Country *</label>
<x-searchable-select
wire:model="country_id"
:options="$countries"
/>
@error('country_id')
<span class="text-red-500 text-sm">{{ $message }}</span>
@enderror
</div>
<div class="mt-4">
<label>City *</label>
<x-searchable-select
wire:model="city_id"
:options="$cities"
/>
@error('city_id')
<span class="text-red-500 text-sm">{{ $message }}</span>
@enderror
</div>
<button wire:click="save" class="mt-4">Save</button>
Real-time validation:
public function updated($propertyName)
{
$this->validateOnly($propertyName);
}
Conditionally disable the dropdown:
<x-searchable-select
wire:model="region_id"
:options="$regions"
:disabled="!$country_id"
placeholder="First select a country"
/>
Hide the clear (×) button:
<x-searchable-select
wire:model="country_id"
:options="$countries"
:clearable="false"
/>
You don't need Eloquent models - plain arrays work too:
public $statuses = [
['id' => 'draft', 'name' => 'Draft'],
['id' => 'published', 'name' => 'Published'],
['id' => 'archived', 'name' => 'Archived'],
];
<x-searchable-select
wire:model="status"
:options="$statuses"
/>
Add custom classes to the component wrapper:
<x-searchable-select
wire:model="country_id"
:options="$countries"
class="border-2 border-blue-500 rounded-xl shadow-lg"
/>
Build reusable components for common patterns:
resources/views/components/country-select.blade.php:
@props(['wireModel'])
<x-searchable-select
wire:model="{{ $wireModel }}"
:options="\App\Models\Country::orderBy('name')->get()"
placeholder="Select a country"
search-placeholder="Search countries..."
{{ $attributes }}
/>
Usage:
<x-country-select wire-model="country_id" />
For thousands of records, implement server-side search:
public $searchTerm = '';
public $countries = [];
public function updatedSearchTerm($value)
{
$this->countries = Country::where('name', 'like', "%{$value}%")
->limit(50)
->get();
}
If you need to customize the component HTML, publish the view file:
php artisan vendor:publish --tag=searchable-select-views
This copies the view to resources/views/vendor/searchable-select/searchable-select.blade.php. Laravel will use your copy instead of the package default.
The component automatically supports dark mode via Tailwind's dark: classes:
<html class="dark">
<!-- Component automatically uses dark:bg-zinc-800, dark:text-white, etc. -->
</html>
The component uses client-side filtering by default. To customize:
searchTerm filtering logic in the published viewupdatedSearchTerm Livewire pattern shown in Advanced FeaturesCauses:
Solutions:
@livewireScripts {{-- This includes Alpine.js --}}
Check browser console for JavaScript errors (F12 → Console)
Ensure you're not loading Alpine.js separately if using Livewire 3+:
<!-- ❌ Remove this if you have Livewire 3+ -->
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
Causes:
optionValue keySolutions:
// ✅ Correct
$this->country_id = 1;
$this->countries = Country::all(); // Contains id=1
// ❌ Incorrect
$this->country_id = 999; // ID doesn't exist in countries
optionValue matches your data structure:// If your data uses 'code' instead of 'id'
$countries = [['code' => 'US', 'name' => 'USA']];
<x-searchable-select
wire:model="country_code"
:options="$countries"
option-value="code" {{-- Must specify 'code' --}}
/>
Causes:
Solutions:
Add the package views to Tailwind's scan paths and rebuild:
Tailwind v4 — add a @source line to resources/css/app.css:
@source '../../vendor/williamug/searchable-select/resources/views/**/*.blade.php';
Tailwind v3 — add to the content array in tailwind.config.js:
export default {
content: [
'./resources/**/*.blade.php',
'./vendor/williamug/searchable-select/resources/views/**/*.blade.php', // Add this
],
}
Rebuild Tailwind CSS:
npm run build
# or for development
npm run dev
Clear Laravel view cache:
php artisan view:clear
Check that your CSS is loading in browser DevTools (Network tab)
Causes:
wire:key on components in loopsSolutions:
wire:key when rendering multiple components in loops:@foreach($forms as $form)
<x-searchable-select
wire:key="country-{{ $form->id }}"
wire:model="forms.{{ $loop->index }}.country_id"
:options="$countries"
/>
@endforeach
$wire.set() immediately. Ensure your Livewire component has a matching updated{PropertyName}() method if you need to react to the change server-side.Causes:
:multiple="true"Solutions:
// ✅ Correct
public $selected_items = [];
// ❌ Incorrect
public $selected_items; // null, not an array
<x-searchable-select
:multiple="true" {{-- Required for multi-select --}}
wire:model="selected_items"
:options="$items"
/>
Causes:
@error directiveSolutions:
<x-searchable-select wire:model="country_id" :options="$countries" />
@error('country_id')
<span class="text-red-500 text-sm">{{ $message }}</span>
@enderror
// Component
public $country_id; // Property name
protected $rules = [
'country_id' => 'required', // Must match property name
];
Causes:
Solutions:
public $searchTerm = '';
public $results = [];
public function updatedSearchTerm($value)
{
$this->results = Product::where('name', 'like', "%{$value}%")
->limit(50)
->get();
}
// ❌ Bad - loads all columns
$this->users = User::all();
// ✅ Good - only id and name
$this->users = User::select('id', 'name')->get();
| Options Count | Recommended Approach |
|---|---|
| < 100 | Client-side filtering (default) - works perfectly |
| 100 - 1,000 | Client-side filtering with wire:key - still performant |
| 1,000+ | Server-side search with Livewire updated* hooks |
1. Server-Side Search:
// Livewire Component
public $searchTerm = '';
public $products = [];
public function updatedSearchTerm($value)
{
$this->products = Product::where('name', 'like', "%{$value}%")
->limit(50)
->get();
}
2. Caching Options:
public function mount()
{
$this->countries = Cache::remember('countries', 3600, function () {
return Country::orderBy('name')->get();
});
}
3. Select Only Needed Columns:
// ❌ Bad - loads all columns
$this->users = User::all();
// ✅ Good - only id and name
$this->users = User::select('id', 'name')->get();
The package includes a comprehensive test suite covering all features.
# Run all tests
composer test
# Run with coverage
composer test -- --coverage
# Run specific test file
./vendor/bin/pest tests/Feature/ComponentTest.php
# Run tests in parallel
./vendor/bin/pest --parallel
The package tests include:
17 tests, 33 assertions - all passing
The package includes a full-featured demo application showcasing all features.
cd demo
composer install
cp .env.example .env
php artisan key:generate
php artisan migrate
php artisan serve
Visit http://localhost:8000
Note: The demo's
composer.jsonreferences the local package via a VCS repository pointing to../. No Packagist fetch needed — it installs directly from the local source.
The demo is a single consolidated page at / showcasing:
Check the demo Livewire component in demo/app/Livewire/DemoPage.php for implementation examples.
See the Dependent/Cascading Dropdowns section for a complete example.
Yes! Publish the view file and edit your copy:
php artisan vendor:publish --tag=searchable-select-views
Your copy lands in resources/views/vendor/searchable-select/searchable-select.blade.php.
Yes, fully compatible with both Livewire 3.x and 4.x.
Use a Livewire server-side search with a custom query:
public function updatedSearchTerm($value)
{
$this->users = User::where('name', 'like', "%{$value}%")
->orWhere('email', 'like', "%{$value}%")
->orWhere('phone', 'like', "%{$value}%")
->get();
}
Yes, initialize your property as an array:
public $selected_items = [1, 3, 5]; // Pre-selected IDs
Yes, the component automatically supports dark mode using Tailwind's dark: classes.
This feature is not built-in, but you can publish the view and add a disabled property check in the options loop.
The component is designed for Livewire. For Inertia.js, consider using a Vue/React select component instead.
Publish the view and customize the option rendering to include icons:
<div>
<img src="{{ $option->flag }}" class="w-4 h-4 inline mr-2">
{{ $option->name }}
</div>
We welcome contributions! Here's how to get started:
Fork the repository
git clone https://github.com/YOUR-USERNAME/searchable-select.git
cd searchable-select
Install dependencies
composer install
Run tests
composer test
Create a feature branch
git checkout -b feature/amazing-feature
Make your changes
Run tests and code style checks
composer test
composer format # Fix code style
Commit your changes
git commit -m 'Add amazing feature'
Push to your fork
git push origin feature/amazing-feature
Open a Pull Request
The project uses:
Run before committing:
composer format # Fix code style
composer test # Run test suite
Found a bug? Please open an issue with:
Have an idea? Open a feature request describing:
Please see CHANGELOG for recent changes.
If you discover any security vulnerabilities, please email the maintainer instead of using the issue tracker.
Inspired by the need for a simple, searchable select component for Laravel Livewire applications.
The MIT License (MIT). Please see License File for more information.
If this package saved you time and effort:
Your support helps maintain and improve this package!
Made with ❤️ for the Laravel community
If this package helped you, please ⭐ star the repository!
How can I help you explore Laravel packages today?