rishadblack/wire-tomselect
| Install | |
|---|---|
composer require rishadblack/wire-tomselect |
|
| Latest Version: | 2.0.2 |
| PHP: | ^8.3 |
| License: | MIT |
| Last Updated: | Oct 7, 2026 |
| Links: | GitHub · Packagist |
WireTomSelect
Searchable Livewire dropdowns backed by Tom Select.
You write one small PHP class that describes an Eloquent query. The package renders the <select>, searches on the server while the user types, keeps the selection in sync with wire:model, and drives the browser side with a single Alpine component. You write no JavaScript.
<livewire:selects.customer-select wire:model="customer_id" label="Customer" />
Contents
- Requirements
- Installation
- Your first dropdown
- Configuration reference
- Blade attributes
- Labels, rich rows and search
- Joins
- Multiple selection
- Dependent dropdowns
- Setting and clearing values from the parent
- Creating new options
- Validation errors
- Security
- Testing
- Customizing the markup
- Troubleshooting
- Upgrading from 1.x
Requirements
| Dependency | Version |
|---|---|
| PHP | 8.3 or newer |
| Laravel | 11, 12 or 13 |
| Livewire | 3 or 4 (Alpine is bundled with Livewire) |
| tom-select (npm) | 2.x |
Installation
1. Install the PHP package and Tom Select.
composer require rishadblack/wire-tomselect
npm install tom-select
2. Load the package script before Livewire starts. With Vite, import it in resources/js/app.js:
import '../../vendor/rishadblack/wire-tomselect/resources/js/wire-tomselect-all.js';
The script registers the wireTomselect Alpine component. Nothing else is needed in the browser.
3. Load the Tom Select stylesheet in resources/css/app.css. Pick the theme that matches your CSS framework:
@import 'tom-select/dist/css/tom-select.bootstrap5.css';
/* or the neutral theme: @import 'tom-select/dist/css/tom-select.css'; */
4. Rebuild your assets.
npm run build
5. Optionally publish the config file to change the defaults:
php artisan vendor:publish --tag=wire-tomselect-config
// config/wire-tomselect.php
return [
'max_options' => 20, // options loaded per request when a component sets no limit
'max_options_limit' => 100, // hard ceiling for every dropdown, whatever it asks for
'load_throttle' => 300, // milliseconds to wait after the last keystroke before searching
'min_search_length' => 1, // characters required before a search request is sent
];
Your first dropdown
1. Create a Livewire component and delete the view it generates, because the package provides its own.
php artisan make:livewire Selects/CustomerSelect
2. Extend SearchComponent and implement two methods. builder() returns the base query. configure() describes how to search and display it.
<?php
namespace App\Livewire\Selects;
use App\Models\Customer;
use Illuminate\Database\Eloquent\Builder;
use Rishadblack\WireTomselect\SearchComponent;
class CustomerSelect extends SearchComponent
{
public function builder(): Builder
{
return Customer::query()->where('is_active', true);
}
public function configure(): void
{
$this->isSearchable();
$this->setSearchField(['name', 'email', 'phone']);
}
}
3. Render it from any Livewire component and bind it with wire:model:
class CreateInvoice extends Component
{
public ?int $customer_id = null;
public function save(): void
{
$this->validate(['customer_id' => 'required|exists:customers,id']);
// ...
}
}
<livewire:selects.customer-select wire:model="customer_id" label="Customer" />
That is the whole setup. The dropdown shows the first 20 active customers ordered by name, searches name, email and phone on the server as the user types, and writes the chosen id to $customer_id.
Configuration reference
Call these methods inside configure(). It runs on every render and every search, so keep it to plain configuration and never run queries there.
| Method | Default | What it does |
|---|---|---|
isSearchable() |
off | Searches on the server while the user types. Without it, only the initial options are shown. |
setSearchField(array $fields) |
['name'] |
Columns matched with LIKE %term%, combined with OR. |
setValueField(string $field) |
id |
Column used as the option value. |
setLabelField(string $field) |
name |
Column used as the option label and the default ORDER BY. |
setMaxOptions(int $max) |
config max_options |
Options loaded per request. Always capped by max_options_limit. |
showRemoveButton() |
off | Shows a "×" button to clear a single select. Multiple selects always have it. |
Behaviour worth knowing:
- If
builder()has noorderBy, the results are ordered by the label field. - If
builder()has nolimit, the option limit is applied, searchable or not. - The selected value is always loaded, even when it falls outside the limit.
public function builder(): Builder
{
// Your own ordering and limit win over the defaults.
return Product::query()->orderByDesc('sold_count')->limit(10);
}
Blade attributes
| Attribute | Purpose |
|---|---|
wire:model |
The parent property that holds the selected value. Use wire:model.live when other dropdowns depend on it. |
label |
Label text. No label is rendered when it is empty. |
placeholder |
Defaults to "Type to select {label}". |
multiple |
Allows several selections. Bind wire:model to an array. |
disabled |
Disables the field. |
max_options |
Overrides the limit for this one instance. Still capped by the config ceiling. |
class, label_class |
Extra CSS classes for the select and the label. |
name |
Optional. Defaults to the wire:model property. See below. |
create_event |
Event dispatched when the user creates a new option. See Creating new options. |
create_load_component |
"action,component" pair for a modal-based creation flow. |
About name. Every dropdown needs a unique name. It builds the DOM ids and is the key used by resets, updates and validation errors. It defaults to the bound property, so wire:model="items.0.product_id" gets the name items.0.product_id and the DOM id items_0_product_id_select. Pass name only when there is no wire:model, or when you need the key to differ from the property.
{{-- These two are equivalent. --}}
<livewire:selects.product-select wire:model="product_id" />
<livewire:selects.product-select wire:model="product_id" name="product_id" />
Labels, rich rows and search
Custom label
Override map() to build the label from several columns. Every item must use the keys id and name.
use Illuminate\Database\Eloquent\Collection;
public function map(Collection $collection): array
{
return $collection->map(fn (Customer $customer): array => [
'id' => $customer->id,
'name' => "{$customer->name} ({$customer->city})",
])->all();
}
Rich rows with HTML
Add an html key to control how a row looks in the dropdown. Render it from a Blade view with the optionHtml() helper, so Blade escapes every value. Keep name as the plain label: it is the fallback text and what tests read. An optional item_html key controls how the selected value looks in the closed control.
public function map(Collection $collection): array
{
return $collection->map(fn (User $user): array => [
'id' => $user->id,
'name' => $user->name,
'html' => $this->optionHtml('selects.user-option', ['user' => $user]),
// 'item_html' => $this->optionHtml('selects.user-item', ['user' => $user]),
])->all();
}
{{-- resources/views/selects/user-option.blade.php --}}
<div class="d-flex align-items-center py-1">
<img src="{{ $user->avatar }}" class="rounded-circle me-2" width="32" height="32" alt="">
<div>
<div class="fw-semibold">{{ $user->name }}</div>
<div class="small text-muted">{{ $user->email }}</div>
</div>
</div>
The browser inserts html and item_html as-is. Always build them with Blade's {{ }} and never with {!! !!} on user data.
If map() reads a relation, eager load it in builder(), or every option costs one extra query:
public function builder(): Builder
{
return User::query()->with('company');
}
Searching columns that are not in the label
Search is decided by setSearchField(), not by the label. A dropdown labelled with the name alone still finds users by email:
public function configure(): void
{
$this->isSearchable();
$this->setSearchField(['name', 'email']); // typing "karim@" finds Karim
}
In searchable mode the browser shows exactly what the server returned. When the search text is cleared or the dropdown closes, the default list comes back.
Custom search logic
Override search() for prefix matching, scopes, relations or full-text search:
public function search(Builder $query, string $search): Builder
{
return $query->where(function (Builder $query) use ($search): void {
$query->where('name', 'like', "{$search}%")
->orWhereHas('company', fn (Builder $company) => $company->where('name', 'like', "{$search}%"));
});
}
Joins
When builder() joins tables, use table-prefixed field names. The prefix is used in SQL, and only the last segment is read from each row.
class OrderSelect extends SearchComponent
{
public function builder(): Builder
{
return Order::query()
->join('customers', 'customers.id', '=', 'orders.customer_id')
->select('orders.*', 'customers.name as customer_name');
}
public function configure(): void
{
$this->isSearchable();
$this->setValueField('orders.id');
$this->setLabelField('orders.reference');
$this->setSearchField(['orders.reference', 'customers.name']);
}
}
Multiple selection
Pass multiple and bind to an array property:
public array $tag_ids = [];
<livewire:selects.tag-select wire:model="tag_ids" label="Tags" :multiple="true" />
Every selected value is loaded on render, even those outside the option limit, in a single query.
Dependent dropdowns
Add #[Reactive] properties to the child dropdown and use them in builder(). When the parent changes the value, the child reloads its options in the same request.
use Livewire\Attributes\Reactive;
class CitySelect extends SearchComponent
{
#[Reactive]
public ?int $country_id = null;
public function builder(): Builder
{
return City::query()->where('country_id', $this->country_id);
}
public function configure(): void
{
$this->isSearchable();
}
}
In the parent, bind the controlling field with wire:model.live and clear the children when it changes:
use Rishadblack\WireTomselect\Traits\WithTomselect;
class AddressForm extends Component
{
use WithTomselect;
public ?int $country_id = null;
public ?int $city_id = null;
public function updatedCountryId(): void
{
$this->reset('city_id');
$this->tomSelectReset('city_id');
}
}
<livewire:selects.country-select wire:model.live="country_id" label="Country" />
<livewire:selects.city-select wire:model="city_id" label="City" :country_id="$country_id" />
A child can depend on several parents. Use ->when() so an empty parent means "no filter":
#[Reactive]
public ?int $country_id = null;
#[Reactive]
public ?int $city_id = null;
public function builder(): Builder
{
return User::query()
->when($this->country_id, fn (Builder $query, int $id) => $query->where('country_id', $id))
->when($this->city_id, fn (Builder $query, int $id) => $query->where('city_id', $id));
}
Setting and clearing values from the parent
To select a value, assign the property. The dropdown re-renders in the same request. If the option is already loaded it is selected; otherwise the package loads it and selects it. This works for cascades too.
public function loadOrder(Order $order): void
{
$this->country_id = $order->country_id;
$this->city_id = $order->city_id; // CitySelect reloads for the new country and selects the city
}
To clear dropdowns, use the WithTomselect trait.
use Rishadblack\WireTomselect\Traits\WithTomselect;
class AddressForm extends Component
{
use WithTomselect;
public function clearForm(): void
{
$this->reset('country_id', 'city_id');
$this->tomSelectReset(['country_id', 'city_id']); // or tomSelectReset() for every dropdown
}
}
To push an option the query cannot return, for example a record outside builder(), use tomSelectUpdate() with the label you already have:
$this->tomSelectUpdate([
'customer_id' => [
'value' => $customer->id,
'options' => ['id' => $customer->id, 'name' => $customer->name],
],
]);
The keys are dropdown names. If the option is already loaded, a plain value is enough: $this->tomSelectUpdate(['customer_id' => 5]).
Creating new options
When the typed text matches nothing, the dropdown can offer an "Add …" row. Two flows are available.
Simple event
Pass create_event. The dropdown dispatches it with the typed text. Create the record and assign the property; the dropdown selects it on its own.
<livewire:selects.customer-select wire:model="customer_id" label="Customer" create_event="customer-create" />
use Illuminate\Support\Str;
use Livewire\Attributes\On;
#[On('customer-create')]
public function createCustomer(string $text): void
{
$name = Str::of($text)->squish()->limit(255, '')->toString();
if ($name === '') {
return;
}
$this->authorize('create', Customer::class);
$this->customer_id = Customer::create(['name' => $name])->id;
}
The typed text comes from the browser. Validate it, authorize the action, and never trust it as-is.
Modal or form component
Pass create_load_component="action,component". The dropdown dispatches a loadComponent event:
{
action: 'open',
component: 'customers.create-modal',
data: { text: 'typed text', field_name: 'customer_id', extra: ['open', 'customers.create-modal'] },
}
<livewire:selects.customer-select wire:model="customer_id" label="Customer"
create_load_component="open,customers.create-modal" />
Your app's modal loader mounts that component and passes data to it. In the modal, use WithTomselect. Its mount hook prefills $name from the typed text and remembers which dropdown opened it. After saving, tomSelectRemoteUpdate() selects the new record in that dropdown.
use Rishadblack\WireTomselect\Traits\WithTomselect;
class CreateModal extends Component
{
use WithTomselect;
public string $name = '';
public function save(): void
{
$customer = Customer::create($this->validate(['name' => 'required|string|max:255']));
$this->tomSelectRemoteUpdate($customer->id, $customer->name);
}
}
Define tomSelectText(string $text) on the modal to handle the typed text yourself instead of having it assigned to $name.
Validation errors
The dropdown shows an error under the field when an alert event of this shape is dispatched. The keys must match the dropdown names.
use Illuminate\Support\Facades\Validator;
public function save(): void
{
$validator = Validator::make(
['customer_id' => $this->customer_id],
['customer_id' => 'required|exists:customers,id'],
);
if ($validator->fails()) {
$this->dispatch('alert', type: 'error', data: ['validation_errors' => $validator->errors()->toArray()]);
return;
}
// ...
}
The error disappears when the user changes the selection.
Security
-
Configuration cannot be changed from the browser. Every configuration property, including the limit, the search and label fields and the loaded options, is
#[Locked]. A tampered request throwsCannotUpdateLockedPropertyException. -
builder()is your authorization boundary. Whatever it returns can be listed and searched by anyone who can load the page. Scope it on shared tables:public function builder(): Builder { return Project::query()->where('team_id', auth()->user()->current_team_id); } -
Browser input is bounded. Ids coming from the browser are reduced to scalars, de-duplicated and capped at
max_options_limit. Search terms are cut at 255 characters. Search values are always bound parameters. -
Rich HTML is trusted output. Build
htmlanditem_htmlwith Blade's escaping, as shown above.
Testing
Dropdown components
Test each dropdown's query rules with Livewire::test(). The loaded options are in the data property.
use App\Livewire\Selects\CustomerSelect;
use App\Models\Customer;
use Livewire\Livewire;
it('searches customers by email', function () {
Customer::factory()->create(['name' => 'Jane Doe', 'email' => 'jane@example.com']);
Customer::factory()->create(['name' => 'John Roe', 'email' => 'john@example.com']);
$customers = Livewire::test(CustomerSelect::class, ['name' => 'customer_id'])
->call('searchBuilder', 'jane@')
->get('data');
expect(collect($customers)->pluck('name')->all())->toBe(['Jane Doe']);
});
it('hides inactive customers', function () {
Customer::factory()->inactive()->create();
$customers = Livewire::test(CustomerSelect::class, ['name' => 'customer_id'])->get('data');
expect($customers)->toBe([]);
});
it('filters cities by the reactive country', function () {
$city = City::factory()->create();
City::factory()->create();
$cities = Livewire::test(CitySelect::class, ['name' => 'city_id', 'country_id' => $city->country_id])->get('data');
expect(collect($cities)->pluck('id')->all())->toBe([$city->id]);
});
Pass name when testing a dropdown on its own, because there is no parent wire:model to derive it from.
Parent components
Assert the dispatched events with their named fields payload:
it('clears the city when the country changes', function () {
Livewire::test(AddressForm::class)
->set('city_id', 5)
->set('country_id', 2)
->assertSet('city_id', null)
->assertDispatched('tom_select_set_reset', fields: ['city_id']);
});
Browser tests with Pest 4
The Tom Select control and options have stable selectors derived from the dropdown name:
| Part | Selector |
|---|---|
| Search input | #{name}_select-ts-control |
| Clickable control | #{name}_class .ts-control |
| Selected item | #{name}_class .ts-control .item |
| An option | #{name}_class .ts-dropdown .option[data-value="{id}"] |
| "Add …" row | #{name}_class .ts-dropdown .create |
it('selects a customer found by email', function () {
$customer = Customer::factory()->create(['name' => 'Jane Doe', 'email' => 'jane@example.com']);
visit('/invoices/create')
->type('#customer_id_select-ts-control', 'jane@')
->click("#customer_id_class .ts-dropdown .option[data-value=\"{$customer->id}\"]")
->assertSeeIn('#customer_id_class .ts-control .item', 'Jane Doe')
->assertNoJavaScriptErrors();
});
Open a dropdown that already has a value by clicking #{name}_class .ts-control. The selected item covers the search input, so clicking the input itself never succeeds.
Customizing the markup
The default view uses Bootstrap classes. Publish it to change the markup:
php artisan vendor:publish --tag=wire-tomselect-views
Edit resources/views/vendor/wire-tomselect/search.blade.php. Keep the x-data, x-on, x-ref="select" and wire:ignore attributes, because the Alpine component depends on them. A Tailwind version only needs different classes on the wrapper, label and error span.
Troubleshooting
| Symptom | Cause and fix |
|---|---|
The select renders as a plain <select> |
wire-tomselect-all.js is missing or loads after Livewire starts. Import it in app.js and rebuild. |
| "requires a unique name attribute or a wire:model binding" | The dropdown has neither. Add wire:model, or name when rendering it alone. |
| Two dropdowns change together | Both resolve to the same name. Bind them to different properties or pass distinct name values. |
| Typing does nothing | isSearchable() is missing from configure(). |
| Fewer options than expected | max_options_limit in the config is lower than setMaxOptions(). |
| A child dropdown does not refresh | The property is not marked #[Reactive], or the parent uses wire:model instead of wire:model.live. |
| "Ambiguous column" SQL error | builder() joins tables. Use table-prefixed value, label and search fields. |
| Options show the wrong text | map() returned keys other than id and name. |
CannotUpdateLockedPropertyException |
Something in the browser tried to change configuration. Set it in configure() or a Blade attribute. |
Upgrading from 1.x
Upgrade to 2.0.1 or later. It runs 1.x code unchanged, including subclasses that redeclare properties (public $max_options = 50;), overrides without return types (search(), mount(), render(), baseMap()), code that reads $this->search_query or calls baseSelectId(), views published from 1.x, and the per-field browser events.
Only two things need attention:
- Remove the facade. The empty
WireTomselectfacade and class are gone. - Update event assertions in tests.
tomSelectUpdate()andtomSelectReset()now send a namedfieldspayload:->assertDispatched('tom_select_set_reset', fields: ['city_id']).
Two behaviours changed on purpose:
- The option limit now applies to non-searchable dropdowns too, capped at 100 by default.
- Configuration properties are locked against changes from the browser.
Once upgraded, move to the 2.x style at your own pace:
- Republish the view if you published it. A 1.x view keeps working, but it carries the old inline script and misses the Alpine component, rich rows and the security fixes in the browser.
- Drop
namewhere it equals thewire:modelproperty. - Replace
tomSelectUpdate()with a plain property assignment where the query can find the value. - Replace the per-field events (
{select_id}_set_value,_set_option,_set_reset) with property assignments andtomSelectReset(). They still work but are deprecated. - Use the new publish tags:
wire-tomselect-configandwire-tomselect-views.
See changelog.md for the full list.
License
MIT. See LICENSE.
Related Packages
A powerful async select component for Laravel Livewire with Alpine.js - a modern...
A beautiful, searchable dropdown component for Laravel Livewire 3 & 4 applicatio...
A soft, modern select/combobox kit for Livewire — single, searchable, async and...
Version History
| Version | Released | PHP | Laravel | License |
|---|---|---|---|---|
| 2.0.2 | ^8.3 | ^11.0| | MIT | |
| 2.0.1 | ^8.3 | ^11.0| | MIT | |
| 2.0.0 | ^8.3 | ^11.0| | MIT | |
| 1.1.8 | ^8.1 | ^9.0| | MIT | |
| 1.1.7 | ^8.1 | ^9.0| | MIT | |
| 1.1.6 | ^8.1 | ^9.0| | MIT | |
| 1.1.5 | ^8.1 | ^9.0| | MIT | |
| 1.1.4 | ^8.1 | ^9.0| | MIT | |
| 1.1.3 | ^8.1 | ^9.0| | MIT | |
| 1.1.2 | ^8.1 | ^9.0| | MIT | |
| 1.1.1 | ^8.1 | ^9.0| | MIT | |
| 1.1.0 | ^8.1 | ^9.0| | MIT | |
| 1.0.7 | ^8.1 | ^9.0| | MIT | |
| 1.0.6 | ^8.1 | ^9.0| | MIT | |
| 1.0.5 | ^8.1 | ^9.0| | MIT | |
| 1.0.4 | ^8.1 | ^9.0| | MIT | |
| 1.0.3 | ^8.1 | ^9.0| | MIT | |
| 1.0.2 | ^8.1 | ^9.0| | MIT | |
| 1.0.1 | ^8.1 | ^9.0| | MIT | |
| 1.0.0 | ^8.1 | ^9.0| | MIT | |
| 0.0.1 | ^8.1 | ^9.0| | MIT |