How to Use Select2 with Livewire 4

Learn how to integrate Select2 with Livewire 4, synchronize values, handle wire:ignore, dynamic options, and avoid common DOM update problems.

Livewire Published
On this page

Select2 enhances a native select with JavaScript-managed markup. Livewire updates the same page through DOM morphing. Without coordination, the widget can disappear after a request, show an old selection, or keep options that the server has already replaced. Repeated initialization can also leave duplicate handlers.

This guide builds a manual integration first, including initial values, resets, and dependent Country → City fields. Then it replaces the integration code with Pharaonic's current Livewire Select2 package.

Quick Answer / TL;DR: Use wire:ignore to protect Select2's DOM, then explicitly synchronize the property and widget in both directions. For dependent selects, replace a keyed ignored block when its parent changes, or update its options yourself.

#Start with a Normal Livewire Select

Assume an existing Laravel application with Livewire 4, Country and City models, and integer primary keys. Each city belongs to a country through country_id.

In app/Livewire/ProfileForm.php:

1<?php
2 
3namespace App\Livewire;
4 
5use App\Models\Country;
6use Illuminate\Contracts\View\View;
7use Livewire\Component;
8 
9class ProfileForm extends Component
10{
11 public ?string $countryId = null;
12 
13 public function render(): View
14 {
15 return view('livewire.profile-form', [
16 'countries' => Country::query()->orderBy('name')->get(),
17 ]);
18 }
19}

In resources/views/livewire/profile-form.blade.php:

1<div>
2 <label for="country">Country</label>
3 <select id="country" wire:model="countryId">
4 <option value="">Choose a country</option>
5 @foreach ($countries as $country)
6 <option value="{{ $country->id }}">{{ $country->name }}</option>
7 @endforeach
8 </select>
9</div>

This works without custom JavaScript. Plain wire:model sends the selection with the next request. Use wire:model.live when choosing a country must immediately load cities.

IDs are nullable strings here because HTML option values and Select2 selections are strings. Validate them on the server; cast after validation if your application needs integers.

#Add Select2 Normally

Load jQuery, Select2's JavaScript, and Select2's stylesheet once in the layout. Expose the same jQuery instance as window.jQuery; load the plugin against that instance. Keep these assets available before initializing a field.

The simplest initialization is:

1window.jQuery('#country').select2({ width: '100%' })

That enhances the field, but it does not provide a Livewire integration. Select2's jQuery change event does not automatically behave like the native event Livewire expects. On subsequent requests, Livewire can also morph markup that Select2 still owns.

#What wire:ignore Actually Solves

Protect the plugin's entire DOM subtree, including the container Select2 inserts beside the select:

1<div wire:ignore>
2 <select id="country">
3 <option value="">Choose a country</option>
4 <option value="1">Egypt</option>
5 </select>
6</div>

wire:ignore protects the DOM. It does not synchronize the value with Livewire.

It also prevents Livewire from updating the options inside that block. Keep validation messages outside it. See Livewire's wire:ignore reference.

#Build the Manual Integration

Use an Alpine data component for each field. Livewire already includes Alpine. Its init() and destroy() methods give the widget a lifetime tied to its own DOM block, including when a keyed block is replaced.

Put this in resources/js/select2-field.js. Load it once through your application's JavaScript entry point, before Livewire starts Alpine. Do not place it in a component view that executes again on every visit.

1document.addEventListener('alpine:init', () => {
2 Alpine.data('manualSelect2', (property) => {
3 let $select
4 let stopWatching
5 
6 const display = (value) => {
7 $select.val(value == null ? '' : String(value))
8 .trigger('change.select2')
9 }
10 
11 return {
12 init() {
13 $select = window.jQuery(this.$refs.input)
14 
15 if (!$select.data('select2')) {
16 $select.select2({ width: '100%' })
17 }
18 
19 display(this.$wire.$get(property))
20 
21 $select.off('.manualSelect2').on('change.manualSelect2', () => {
22 const value = $select.val() ?? ''
23 const current = this.$wire.$get(property) ?? ''
24 
25 if (String(current) !== value) {
26 this.$wire.$set(property, value, true).catch(console.error)
27 }
28 })
29 
30 stopWatching = this.$wire.$watch(property, display)
31 },
32 
33 destroy() {
34 stopWatching?.()
35 $select?.off('.manualSelect2')
36 
37 if ($select?.data('select2')) {
38 $select.select2('destroy')
39 }
40 },
41 }
42 })
43})

Use it in the view:

1<div>
2 <label>Country</label>
3 <div wire:ignore x-data="manualSelect2('countryId')">
4 <select x-ref="input" aria-label="Country">
5 <option value="">Choose a country</option>
6 @foreach ($countries as $country)
7 <option value="{{ $country->id }}">{{ $country->name }}</option>
8 @endforeach
9 </select>
10 </div>
11</div>

This helper owns the binding, so the manual select has no wire:model. It uses the closest component's $wire through Alpine; it needs no global field ID. The true argument sends changes immediately, which the dependent example needs.

The watcher returns a disposer. Call it when the field disappears, even when the Livewire component itself remains mounted. Remove only your own namespaced handlers, then destroy the widget. The initialization guard and namespaced binding prevent repeated setup from stacking handlers.

Server-to-widget updates trigger only change.select2, so they refresh Select2 without re-entering the handler that sends changes to Livewire. These APIs are available in the Livewire 4 JavaScript API.

#Initial Values: Three States Must Agree

For an edit form, initialize the property before rendering, for example:

1public ?string $countryId = '5';

There are three separate states: the Livewire property, the native select's value, and Select2's displayed selection. The helper reads the first, sets the second with .val(), and refreshes the third with change.select2.

The option with value 5 must already exist. Setting a value does not fetch a missing option or its label. With remote data, add the selected option first, as described in Select2's programmatic selection documentation.

#Reset the Property and the Widget

With the original default of null, reset the field on the server:

1public function resetForm(): void
2{
3 $this->reset('countryId');
4}

The manual $watch callback then updates the ignored select and its visible widget. wire:ignore alone would leave the old selection visible.

reset() restores the property's declared default. If you declare '5' as its default, resetting restores '5'; explicitly assign null when you mean to clear it.

#Dynamic Options: Country → City

Changing a country changes both the city's available options and its selected value:

1public ?string $countryId = null;
2 
3public ?string $cityId = null;
4 
5public function updatedCountryId(): void
6{
7 $this->cityId = null;
8}

Query cities for the current country in render(). This avoids keeping a second public copy of the database results. The complete component appears below.

For the manual integration, key the ignored city block by country:

1<div wire:ignore wire:key="manual-city-{{ $countryId ?: 'none' }}"
2 x-data="manualSelect2('cityId')">
3 <select x-ref="input" aria-label="City">
4 <option value="">Choose a city</option>
5 @foreach ($cities as $city)
6 <option value="{{ $city->id }}">{{ $city->name }}</option>
7 @endforeach
8 </select>
9</div>

When the country changes, Livewire replaces the keyed block. Alpine disposes the old watcher, removes its handler, destroys Select2, and initializes the new field with the new options. Unrelated renders keep the existing widget.

If options change while the country stays the same, include an options revision in the key too. A stable key cannot refresh an ignored option list.

For small client-owned lists, you can instead replace native options with new Option(...) and notify Select2 without destroying it. Close an open dropdown before replacing its options. With server-rendered Blade options, keyed replacement keeps the server as the single source for option markup and limits recreation to actual parent changes.

#Where the Manual Work Accumulates

The reusable helper keeps the manual approach reasonable. You still own initialization, the ignored boundary, both directions of state synchronization, initial values, reset behavior, keyed option replacement, watcher disposal, listener removal, and widget destruction.

Country, City, Language, and Timezone can all reuse the helper; four copied scripts are unnecessary. But the helper is now an integration layer your application must maintain and verify after framework or plugin upgrades.

#Introduce Pharaonic Livewire Select2

The current Livewire Select2 package provides that integration layer through Livewire Assistant. Its 4.x source requires Livewire 4.3.4 or later in the 4.x line. Earlier package documentation may describe a different API.

1composer require pharaonic/livewire-select2

Keep loading jQuery, Select2, and Select2's CSS yourself. Add this once in the layout:

1@livewireScripts
2@livewireSelect2Scripts

The directive includes the Assistant runtime and Select2 integration scripts. No Node build is required for the package itself. Do not load its scripts separately again on every field.

The examples below were checked against package 4.0.0, Livewire 4.4.8, and Select2 4.1.0. They match the resources/js implementation and dependent-select browser fixtures in the package's 4.x source.

#The Same Country Field, with Less Integration Code

#Manual Version

1<div wire:ignore x-data="manualSelect2('countryId')">
2 <select x-ref="input" aria-label="Country">
3 <option value="">Choose a country</option>
4 @foreach ($countries as $country)
5 <option value="{{ $country->id }}">{{ $country->name }}</option>
6 @endforeach
7 </select>
8</div>

This also requires the JavaScript helper shown earlier.

#Pharaonic Version

1<div wire:ignore>
2 <select data-pharaonic="select2" wire:model.live="countryId"
3 data-options='{"width":"100%"}' aria-label="Country">
4 <option value="">Choose a country</option>
5 @foreach ($countries as $country)
6 <option value="{{ $country->id }}">{{ $country->name }}</option>
7 @endforeach
8 </select>
9</div>

Remove the manual helper from package-managed fields. The package initializes Select2, reads the initial property value, writes user changes, watches property updates, and cleans up the widget and its handlers when removed. You still choose where wire:ignore belongs.

Plain wire:model defers the value until the next request. Use .live for immediate dependent updates. data-property="countryId" is also supported, takes precedence over wire:model, and always sends immediately. The binding accepts dot paths such as address.countryId.

#Dynamic Country → City with Pharaonic

Keep the same properties, updatedCountryId(), and city query. Replace the helper attributes with package attributes, retaining the key:

1<div wire:ignore wire:key="package-city-{{ $countryId ?: 'none' }}">
2 <select data-pharaonic="select2" wire:model.live="cityId"
3 data-options='{"width":"100%"}' aria-label="City">
4 <option value="">Choose a city</option>
5 @foreach ($cities as $city)
6 <option value="{{ $city->id }}">{{ $city->name }}</option>
7 @endforeach
8 </select>
9</div>

The package tears down the removed widget and binds its replacement. You maintain the dependency and key; it maintains the plugin lifecycle.

Alternatively, omit wire:ignore on the dynamic field. Livewire then morphs the options, and the package rebuilds the widget after morphing. This avoids a changing key but recreates Select2 on component renders and closes an open dropdown. The keyed ignored approach avoids that on unrelated requests.

Neither approach observes arbitrary database changes. The component must render fresh options, and an ignored field must receive a changed key when its list changes.

#Several Fields Do Not Need Several Scripts

The manual helper is reusable through x-data="manualSelect2('language')" and x-data="manualSelect2('timezone')". Package-managed fields share the same runtime without that application helper:

1<div>
2 <div wire:ignore>
3 <select data-pharaonic="select2" wire:model="language" aria-label="Language">
4 <option value="en">English</option>
5 <option value="ar">Arabic</option>
6 </select>
7 </div>
8 <div wire:ignore>
9 <select data-pharaonic="select2" wire:model="timezone" aria-label="Timezone">
10 <option value="Africa/Cairo">Cairo</option>
11 <option value="UTC">UTC</option>
12 </select>
13 </div>
14</div>

Declare matching component properties, for example public string $language = 'en'; and public string $timezone = 'Africa/Cairo';. Add these alongside Country and City without extra initialization calls. Repeated rows need stable, unique wire:key values; use a record's identity rather than its loop position.

#Multiple Selection

The package supports a native multiple select bound to an array:

1public array $tagIds = [];
1<div wire:ignore>
2 <select multiple data-pharaonic="select2" wire:model="tagIds" aria-label="Tags">
3 @foreach ($tags as $tag)
4 <option value="{{ $tag->id }}">{{ $tag->name }}</option>
5 @endforeach
6 </select>
7</div>

Pass $tags from the component. A selection produces an array of strings, such as ['3', '8']; clearing produces []. Initial integer values are converted to strings for display. data-tags additionally permits creating free-text options, so do not enable it for a restricted list of database IDs.

The earlier manual helper handles single values only. To support multiple values manually, normalize values to arrays, compare their contents, and apply the array with .val(values).trigger('change.select2').

#Validate the Property on the Server

Select2 provides the UI. Laravel and Livewire still validate the submitted property:

1use Livewire\Attributes\Validate;
2 
3#[Validate('required|exists:countries,id')]
4public ?string $countryId = null;
5 
6#[Validate(['tagIds' => 'array', 'tagIds.*' => 'integer|distinct|exists:tags,id'])]
7public array $tagIds = [];

Call $this->validate() before saving. An array rule alone does not validate the IDs inside it. For dependent fields, also check that the city belongs to the chosen country; the full example uses a constrained Rule::exists for this.

Render @error messages outside ignored blocks so Livewire can update them.

#Select2 Inside a Modal

Select2 normally attaches its dropdown to the document body. Modal focus handling and stacking can interfere with that dropdown. In a manual integration, pass the modal as dropdownParent:

1window.jQuery('#country').select2({
2 dropdownParent: window.jQuery('#user-modal'),
3})

With the package, set data-parent="#user-modal" on the select. An empty data-parent finds the closest .modal, dialog, or [role="dialog"]:

1<div role="dialog" id="user-modal">
2 <div wire:ignore>
3 <select data-pharaonic="select2" wire:model="countryId" data-parent
4 aria-label="Country">
5 <option value="">Choose a country</option>
6 <option value="1">Egypt</option>
7 </select>
8 </div>
9</div>

This is Select2 dropdown placement and focus behavior, rather than a Livewire state issue. See Select2's modal troubleshooting.

#Common Mistakes

Mistake Result Fix
Add only wire:ignore Protected markup, unsynchronized state Bind user changes and watch property changes
Initialize after every request Repeated setup and possible orphaned markup Mount once per field lifetime; destroy on removal
Register change repeatedly Duplicate updates or requests Namespace and remove your handlers
Replace ignored options on the server Old options remain visible Replace a keyed block or manage options explicitly
Call .val() without notifying Select2 Native value changes but displayed label stays old Trigger change.select2 for display-only synchronization
Change only the widget Server still holds another value Write to the Livewire property
Change only the server property Ignored UI displays the previous value Watch the property and refresh the widget
Use .change expecting an immediate request Dependent options wait for another action Use wire:model.live on Livewire 4
Copy old message.processed or emit examples Lifecycle code targets an older Livewire version Use current component APIs and scoped cleanup

Do not combine the manual helper with the package on one select. With package-managed wire:change, prefer an updatedCountryId() hook for dependent state: the package dispatches a native change for wire:change, which additional jQuery change listeners can also receive.

#Manual vs Pharaonic

Responsibility Manual helper Current Pharaonic package
Load jQuery, Select2, and CSS Application Application
Initialize Select2 Helper Package
Add wire:ignore Blade Blade; optional with package morph rebuilding
Write choices to Livewire $wire.$set Package binding
Display initial values and server resets $get and $watch Package binding
Remove handlers and destroy widgets Alpine destroy() Assistant lifecycle and package teardown
Dependent options Server query plus keyed replacement Same query and key, or package morph rebuilding
Several fields Reuse application helper Reuse package attributes
Multiple values Extend helper for arrays Built in
Validation and dependency rules Application Application

#Full Manual Example

This complete Country → City form uses the single-select helper above. Load that helper once before Alpine starts, with jQuery, Select2, and its CSS already available. Mount the component with <livewire:profile-form />.

In app/Livewire/ProfileForm.php:

1<?php
2 
3namespace App\Livewire;
4 
5use App\Models\City;
6use App\Models\Country;
7use Illuminate\Contracts\View\View;
8use Illuminate\Validation\Rule;
9use Livewire\Component;
10 
11class ProfileForm extends Component
12{
13 public ?string $countryId = null;
14 
15 public ?string $cityId = null;
16 
17 public bool $submitted = false;
18 
19 public function updatedCountryId(): void
20 {
21 $this->cityId = null;
22 $this->submitted = false;
23 $this->resetValidation('cityId');
24 }
25 
26 protected function rules(): array
27 {
28 return [
29 'countryId' => ['required', 'integer', 'exists:countries,id'],
30 'cityId' => [
31 'required',
32 'integer',
33 Rule::exists('cities', 'id')->where('country_id', $this->countryId),
34 ],
35 ];
36 }
37 
38 public function submit(): void
39 {
40 $this->validate();
41 
42 // Persist the validated IDs using your application's authorization rules.
43 $this->submitted = true;
44 }
45 
46 public function resetForm(): void
47 {
48 $this->reset('countryId', 'cityId', 'submitted');
49 $this->resetValidation();
50 }
51 
52 public function render(): View
53 {
54 return view('livewire.profile-form', [
55 'countries' => Country::query()->orderBy('name')->get(),
56 'cities' => City::query()
57 ->where('country_id', $this->countryId)
58 ->orderBy('name')
59 ->get(),
60 ]);
61 }
62}

In resources/views/livewire/profile-form.blade.php:

1<form wire:submit="submit">
2 <div wire:ignore x-data="manualSelect2('countryId')">
3 <select x-ref="input" aria-label="Country">
4 <option value="">Choose a country</option>
5 @foreach ($countries as $country)
6 <option value="{{ $country->id }}">{{ $country->name }}</option>
7 @endforeach
8 </select>
9 </div>
10 @error('countryId') <p role="alert">{{ $message }}</p> @enderror
11 
12 <div wire:ignore wire:key="manual-city-{{ $countryId ?: 'none' }}"
13 x-data="manualSelect2('cityId')">
14 <select x-ref="input" aria-label="City">
15 <option value="">Choose a city</option>
16 @foreach ($cities as $city)
17 <option value="{{ $city->id }}">{{ $city->name }}</option>
18 @endforeach
19 </select>
20 </div>
21 @error('cityId') <p role="alert">{{ $message }}</p> @enderror
22 
23 <button type="submit" wire:loading.attr="disabled">Submit</button>
24 <button type="button" wire:click="resetForm" wire:loading.attr="disabled">Reset</button>
25 @if ($submitted)
26 <p role="status">Selection validated.</p>
27 @endif
28</form>

The server queries options; the changing key replaces the city's ignored subtree. The helper initializes the new instance and cleans up the old one. A reset changes the country key and clears both values; resetting only the city is handled by its watcher.

For an edit form, load both property values in mount() and ensure the initial city query contains the selected city. Do not call updatedCountryId() while loading those initial values, since it deliberately clears the city.

#Full Pharaonic Example

Use the exact same ProfileForm PHP component above: the properties, validation, country hook, query, submit action, and reset action are unchanged. Load @livewireSelect2Scripts once in the layout and use this complete replacement view:

1<form wire:submit="submit">
2 <div wire:ignore>
3 <select data-pharaonic="select2" wire:model.live="countryId"
4 data-options='{"width":"100%"}' aria-label="Country">
5 <option value="">Choose a country</option>
6 @foreach ($countries as $country)
7 <option value="{{ $country->id }}">{{ $country->name }}</option>
8 @endforeach
9 </select>
10 </div>
11 @error('countryId') <p role="alert">{{ $message }}</p> @enderror
12 
13 <div wire:ignore wire:key="package-city-{{ $countryId ?: 'none' }}">
14 <select data-pharaonic="select2" wire:model.live="cityId"
15 data-options='{"width":"100%"}' aria-label="City">
16 <option value="">Choose a city</option>
17 @foreach ($cities as $city)
18 <option value="{{ $city->id }}">{{ $city->name }}</option>
19 @endforeach
20 </select>
21 </div>
22 @error('cityId') <p role="alert">{{ $message }}</p> @enderror
23 
24 <button type="submit" wire:loading.attr="disabled">Submit</button>
25 <button type="button" wire:click="resetForm" wire:loading.attr="disabled">Reset</button>
26 @if ($submitted)
27 <p role="status">Selection validated.</p>
28 @endif
29</form>

There is no component-specific JavaScript. Both forms implement the same behavior; the package owns the Select2/Livewire integration layer in the second form. Keep your server queries, validation, authorization, and dependent-field rules.

#What the Package Saves You from Writing

For these examples, remove the application's manualSelect2 helper entirely. The package supplies:

  • Select2 initialization for marked fields, including fields added after a Livewire update.
  • Initial property-to-widget synchronization and subsequent server-to-widget updates, including resets.
  • The change handler that writes values using the binding's immediate or deferred behavior.
  • Namespaced event cleanup and widget destruction when fields disappear.
  • Integration with Livewire Assistant for component ownership, morph updates, and navigation cleanup.
  • Array normalization for multiple selects and optional creation of missing free-text tag options.

It does not load the Select2 dependencies, fetch your city records, validate IDs, invent missing labels for ordinary selections, or decide when an ignored option list needs a new key. Outside wire:ignore, rebuilding on renders is a deliberate trade-off, not a promise that the widget remains open.

The package cleans up widgets before wire:navigate leaves a page and mounts them again when a page is shown. Its runtime scripts use data-navigate-once; your dependency assets should also follow your application's navigation loading strategy.

#Choosing an Integration

Livewire owns application state; Select2 owns its JavaScript UI. The manual helper is a reasonable choice for a small application that already maintains similar Alpine adapters. Pharaonic replaces that adapter when you want the shared lifecycle and binding behavior maintained in a package.

Use the package documentation for configuration and the GitHub source to check version-specific behavior. In either approach, keep dependent queries and validation in the Livewire component.