Livewire Assistant
Livewire Assistant is the shared foundation that binds JavaScript widgets such as Select2 to Livewire components. You mark an element with data-pharaonic, register a small integration once, and the Assistant finds the element, resolves the Livewire component that owns it, gives the integration a component bridge, and mounts, updates, and cleans the widget up as Livewire changes the page.
Declarative markup
A data-pharaonic attribute names the integration; data-property, data-component-id and data-options configure it.
Correct ownership
Elements bind to their nearest Livewire component, or to an explicit component id, never to a parent by accident.
Component bridge
Read state, write properties, call actions, and watch property changes through one small API.
Lifecycle handled
Instances mount once, update on relevant DOM changes, remount when their binding changes, and clean up on removal and navigation.
Observable failures
Every automatic failure becomes a pharaonic:assistant:error event with a stable code; nothing fails silently.
No Node build required
The runtime ships prebuilt inside the Composer package and loads through one Blade directive.
This documentation covers the 2.x line, which supports Livewire 2. Each Livewire major gets its own line.
#Installation
Install the package with Composer and load its runtime once per page.
#Requirements
- PHP 8.0 to 8.3, as supported by your Laravel version
- Laravel 7.30+, 8.75+, 9, or 10
- Livewire 2.12.7 or later (see Compatibility)
- A modern browser (ES2019); Internet Explorer is not supported
#Composer Installation
composer require pharaonic/livewire-assistantLaravel discovers Pharaonic\Livewire\Assistant\AssistantServiceProvider automatically. There is nothing to publish and no configuration file.
Livewire 2.12.7 and later have no known advisories, but every Laravel 7, 8, 9, and 10 release has security advisories that no release in its major fixes. Adding this package to an existing application works, because Composer leaves locked packages alone. When you later install or update the framework, recent Composer versions refuse to resolve it and name each blocked package.
If you accept the risk, allow those packages in your application's composer.json. These lists resolve a fresh application with Livewire 2:
| Laravel | Packages to allow |
|---|---|
| 7 | laravel/framework, league/commonmark, league/flysystem |
| 8 | laravel/framework, league/flysystem |
| 9 on PHP 8.0 | laravel/framework, symfony/http-foundation, symfony/mailer, symfony/mime, symfony/process, symfony/routing, symfony/yaml |
| 9 on PHP 8.1+, 10 | laravel/framework |
composer config --json policy.advisories.ignore '{"laravel/framework": "No fixed release within this Laravel major."}'#Load the Runtime
Add @livewireAssistantScripts to your layout. It can go before or after @livewireScripts.
1<body>2 {{ $slot }}3 4 @livewireScripts5 @livewireAssistantScripts6</body>The directive renders one <script> tag per page, even when layouts, partials, and components call it several times. It uses Laravel's own @once mechanism.
1<script src="https://example.com/pharaonic/livewire-assistant/assistant.js?id=3f9a1c2b7d4e5f60" data-turbo-eval="false" data-turbolinks-eval="false"></script>#Content Security Policy
Pass a nonce when your CSP uses one. It is escaped before rendering.
1@livewireAssistantScripts(['nonce' => $cspNonce])Only the first call on a page renders, so pass the nonce there (normally in the layout). nonce is the only option. An unknown option or a non-string nonce throws an InvalidArgumentException while the view renders. The runtime never uses eval, inline scripts, or remote code, so script-src 'self' (or the nonce) is enough.
#How the Script Is Served
The package registers one route, outside the web middleware group, so it uses no session or cookies:
| Route | Name |
|---|---|
GET /pharaonic/livewire-assistant/assistant.js | pharaonic.livewire-assistant.script |
It serves dist/livewire-assistant.js from the installed package. The id query value is a hash of the file content:
- a request whose
idmatches the installed build is cached for a year (immutable); - any other request is served with
no-cache.
Your web server must pass this URL to Laravel, the same way it passes Livewire's own /livewire/livewire.js. A rule that serves every .js URL from public/ and returns 404 otherwise breaks both.
After composer update, the hash changes, so browsers fetch the new runtime on the next page load. There is no asset to publish, and nothing can go stale. The route works with php artisan route:cache.
To load the script without the directive, use the route name:
1<script src="{{ route('pharaonic.livewire-assistant.script') }}" data-turbo-eval="false" data-turbolinks-eval="false"></script>Without an id, the browser revalidates on every load. Prefer the directive.
#ES Module
If you bundle your JavaScript, you can import the ES module build from the Composer package instead. It has the same named exports as window.Pharaonic.assistant.
1import { register } from '../../vendor/pharaonic/livewire-assistant/dist/livewire-assistant.esm.js'2 3register('example', {4 mount(context) {5 // ...6 },7})The ES module and the browser build share one runtime per page. Whichever loads first installs it, and the other reuses it, so you can use the module in your bundle and keep the directive in your layout without registering anything twice.
The runtime starts by itself. Next, mark elements and register an integration.
#Basic Usage
An integration has two parts: markup that names it, and a JavaScript definition registered once.
#Markup
1<div>2 <input type="text" value="{{ $name }}" data-pharaonic="live-input" data-property="name">3</div>| Attribute | Meaning |
|---|---|
data-pharaonic | The integration name. Exactly one name per element; surrounding whitespace is trimmed. Required. |
data-property | A property path such as country_id or address.city. Optional; only integrations that bind a property need it. |
data-component-id | An explicit Livewire component id. Optional; it takes precedence over the nearest component. |
data-options | A JSON object handed to the integration. Optional; parsed with JSON.parse, never evaluated. |
#Registration
Register the integration from your own script. It can load before or after the Assistant; see Load Order.
1const liveInput = { 2 mount(context) { 3 const { element, property, component } = context 4 5 const apply = (value) => { 6 const next = value === null || value === undefined ? '' : String(value) 7 8 // Never overwrite what the user is still typing. 9 if (element !== document.activeElement && element.value !== next) {10 element.value = next11 }12 }13 14 const onInput = () => component.set(property, element.value)15 16 apply(component.get(property))17 element.addEventListener('input', onInput)18 component.watch(property, apply)19 20 return () => element.removeEventListener('input', onInput)21 },22}23 24if (window.Pharaonic?.assistant) {25 window.Pharaonic.assistant.register('live-input', liveInput)26} else {27 (window.PharaonicAssistantQueue ??= []).push((assistant) => assistant.register('live-input', liveInput))28}The Assistant mounts every matching element when Livewire is ready, mounts new ones when Livewire adds them, and runs the returned cleanup when they leave the page.
While the user types faster than Livewire requests return, watch() reports values they have already typed past, so the integration leaves the focused input alone. Widgets that rebuild their own markup, such as Select2, also need wire:ignore; see Targeting and the DOM.
Subscriptions made through context.component.watch() belong to the mounted instance and end with it. You only clean up what you created yourself, such as DOM listeners and widgets.
#Writing Integrations
An integration is a plain object registered under a name. The Assistant calls it; it never calls the Assistant's internals.
#register(name, definition)
1const unregister = Pharaonic.assistant.register('example', { 2 mount(context) { 3 // Create the widget and attach listeners. 4 return () => { 5 // Destroy the widget and remove those listeners. 6 } 7 }, 8 update(context) { 9 // Optional: refresh the existing widget after its element changed.10 },11})namestarts with a letter or digit and continues with letters, digits,.,:,_or-; surrounding whitespace is trimmed. Anything else throwsinvalid-registration.definition.mountis required;definition.updateis optional. Anything else throwsinvalid-registration.- Registering the same definition object under the same name again is harmless and returns the same
unregister. - A different definition under a taken name throws
registration-conflictand leaves the live instances alone. - Registering after startup mounts every matching element on the page right away.
unregister()destroys that registration's instances and frees the name. Calling it again does nothing.
#mount(context)
mount runs once per element, synchronously, when the element, its integration, and its component are all available. It returns a cleanup function or undefined.
| Outcome | What happens |
|---|---|
Returns a function or undefined | The instance is kept. |
| Throws | Cleanups already registered with onCleanup run, mount-failed is reported, and the element is not retried until its binding changes or you destroy() it. |
| Returns a promise | Treated as a failure (mount-async); cleanups registered with onCleanup run. Whatever the promise does later is up to the integration. |
| Returns anything else | mount-failed. |
A failure in one integration never stops other elements or integrations from mounting.
#update(context)
When Livewire changes a mounted element, or anything inside it, and the binding is unchanged, the Assistant calls update once for that change, with the same context as mount. Several notifications from one Livewire update are coalesced into one call. Errors are reported as update-failed and the instance is kept.
Elements inside wire:ignore are not changed by Livewire, so update never runs for them. Use watch() for server-side changes instead; see Targeting and the DOM.
#When an instance is remounted
The binding of an element is its integration name, data-property, data-component-id, parsed data-options, and the component it resolves to. If any of these changes, the old instance is destroyed and a new one is mounted. update is not called.
Options are compared as JSON values, so whitespace and key order do not count: {"a":1,"b":2} and { "b": 2, "a": 1 } are the same binding.
#Cleanup
The cleanup returned by mount, every onCleanup callback, and every watcher are disposed exactly once, last in, first out. A throwing disposer is reported as cleanup-failed, and the others still run.
Instances are cleaned up when:
- their element leaves the page (checked right after each Livewire update);
- their component is removed or replaced;
- their binding changes;
- you call
destroy()on the element or an ancestor; - their integration is unregistered;
- Turbolinks or Turbo is about to cache the page.
An element that only moves inside its component keeps its instance.
#The context
1{2 element, // the HTMLElement3 property, // data-property, or null4 options, // the parsed data-options object, {} when absent5 component, // the component bridge, see Component Bridge6 onCleanup(callback),7}The context and the bridge are frozen. onCleanup(callback) adds a disposer owned by this instance; after the instance is gone it runs the callback immediately. Old contexts never act on a component that was destroyed or replaced: their bridge throws (get, watch) or rejects (set, call) with stale-context.
#Component Bridge
context.component is the only way an integration talks to Livewire.
| Member | Returns |
|---|---|
id | The component id (string). |
get(path) | A copy of the current client-side value, or undefined. Synchronous. |
set(path, value, { live = true } = {}) | Promise<undefined> |
call(method, ...args) | Promise of the method's return value |
watch(path, callback) | An unsubscribe function. |
#Property paths
get, set, and watch take a property name or a dot path: country_id, address.city, items.0.name. The first segment must be a valid identifier and no segment may be empty. An invalid path, including null from an element without data-property, throws (get, watch) or rejects (set) with invalid-property. The bridge never guesses a property name.
#get(path)
Returns the component state the browser has right now. It is not a server request. The value is a copy, so changing it does not change the component.
1component.get('address.city') // "Cairo"2component.get('address.zip') // undefined3component.get('country_id') // 0 (never coerced)#set(path, value, { live })
Writes the value to the component property. The promise resolves after the request that carried this write has finished and the page was updated, and rejects with request-failed if that request failed.
{ live: true }, the default, sends the write right away, likewire:model.get()returns the new value once the round trip finished.{ live: false }defers the write, likewire:model.defer: Livewire sends it with the component's next request, and the promise resolves when that request finishes. Until then,get(path)with the same path already returns the deferred value.
Values are sent as they are: 0, '', null, false, and arrays all reach the server unchanged.
#call(method, ...args)
Calls a public component method. The promise resolves with the method's return value after the request has finished, and rejects with request-failed if it failed. A method that returns nothing, or null, resolves to undefined.
1const total = await component.call('total', 'EUR') // 42.5#watch(path, callback)
Calls callback(value, previousValue) each time a Livewire update changes the value at path, whether the change was made on the server or came from the browser. Values are compared as JSON, so a re-render with an equal value does not call it. The callback does not fire immediately; read the initial value with get().
1const unwatch = component.watch('country_id', (value, previous) => {2 // ...3})4 5unwatch() // idempotentWatchers end with their instance; you only need unwatch to stop earlier. A throwing callback is reported as watcher-failed and does not affect other watchers.
Targeting a component from the browser grants nothing. Livewire actions and properties keep their normal server-side validation and authorization; treat every value they receive as user input.
#Targeting and the DOM
#Nearest component
Without data-component-id, an element belongs to the closest element with a wire:id, which may be the element itself. An element inside a nested component belongs to that child, never to the parent around it.
1<div>2 <select data-pharaonic="select2" data-property="status"></select> {{-- order --}}3 4 @livewire('address-form') {{-- its own elements bind to address-form --}}5</div>An element outside every component and without an explicit id is reported as component-not-found once and left alone.
#Explicit component
data-component-id binds an element to a specific component wherever it is. In a Livewire 2 view, the component id is $this->id.
1<select data-pharaonic="select2" data-property="status" data-component-id="{{ $this->id }}"></select>If no component has that id, the Assistant reports component-not-found and does not fall back to another component. The element is mounted later if a component with that id initializes; there is no polling.
#wire:ignore
Livewire morphs a component's DOM to match the server HTML after each request. Widgets such as Select2 hide the original element and insert their own markup, which morphing would remove. Wrap such widgets in wire:ignore yourself; the Assistant never adds or changes Livewire attributes.
1<div wire:ignore wire:key="country-field">2 <select data-pharaonic="select2" data-property="country_id">...</select>3</div>Inside wire:ignore, Livewire does not touch the element at all:
- the instance and its widget survive every request;
update()never runs, and new server HTML for the element is ignored, including changeddata-options;- server-side property changes reach the widget only through
component.watch().
Livewire morphs siblings by position. When an ignored block can appear or disappear next to other ignored blocks, give each a unique wire:key. Otherwise Livewire compares the wrong blocks, skips the one that should change, and removes a different one. Livewire also uses an element's id as its key when there is no wire:key, so never reuse an inner element's id as a wire:key.
Elements that are not ignored can still be integrations, for example a native input that only listens to events. Livewire then updates their attributes, and the Assistant calls update() or remounts them. Do not store integration state in attributes of such elements: the next morph removes them and counts as a change.
#Dynamic elements
Elements that Livewire adds are mounted after Livewire finishes the update, and removed ones are cleaned up right after it. Elements you add or remove yourself, outside a Livewire update, need one call:
1Pharaonic.assistant.scan(container) // mounts the container and everything matching inside it2Pharaonic.assistant.destroy(container) // cleans up the container and everything inside itBoth include the root element itself, are safe to repeat, and never duplicate an instance. destroy() also forgets failed mounts, so a following scan() tries them again. The Assistant does not observe the whole document for mutations.
#Navigation
Livewire 2 works with Turbolinks and Turbo through Livewire's livewire-turbolinks adapter, which restarts Livewire on every visit. Just before Turbolinks or Turbo caches a page, the Assistant cleans every instance up, so the cached snapshot holds the original elements and not initialized widgets. When the page comes back, through a link, back, or forward, the elements are mounted again with the new components. Full page loads need nothing special.
#Load Order
The runtime, Livewire, and your integrations can load in any order.
#Livewire
@livewireAssistantScripts can come before or after @livewireScripts, or be loaded later. The Assistant connects when Livewire is available, and mounts elements once Livewire has initialized its components, including when Livewire already started.
| Situation | Result |
|---|---|
| Livewire 2 loads before or after the Assistant | Works, no duplicate hooks |
| Livewire never loads | livewire-missing after the page load event |
| Another Livewire major is on the page | livewire-unsupported; nothing mounts |
#Integrations
An integration must work whether the Assistant has loaded yet or not, and must never download or inject the Assistant itself. Use the global queue:
1const registerSelect2 = (assistant) => {2 assistant.register('select2', select2Definition)3}4 5if (window.Pharaonic?.assistant) {6 registerSelect2(window.Pharaonic.assistant)7} else {8 (window.PharaonicAssistantQueue ??= []).push(registerSelect2)9}When the runtime loads, it runs every queued callback once with window.Pharaonic.assistant. A failing callback is reported as queue-callback-failed, and the others still run. After that, window.PharaonicAssistantQueue.push() runs callbacks immediately, so a late push is never lost.
#Why integrations do not load the runtime
There must be exactly one runtime per page: one registry, one set of Livewire hooks, one owner per element. If every integration loaded its own copy, they would compete for the same elements and leak listeners. The runtime arrives once, from the Composer dependency through @livewireAssistantScripts, or from your own bundle through the ES module. Loading it twice by mistake is harmless: the second copy reuses the first.
#Existing globals
The runtime adds assistant to window.Pharaonic and keeps every member already there.
#Select2 Walkthrough
This is the Select2 integration the package's browser suite runs against real Livewire 2 components. jQuery and Select2 are your application's dependencies; the Assistant does not ship them.
#1. The component
1<?php 2 3namespace App\Http\Livewire; 4 5use Livewire\Component; 6 7class Profile extends Component 8{ 9 public $country = '2';10 11 public $tags = ['a'];12 13 public function render()14 {15 return view('livewire.profile');16 }17}#2. The view
1<div> 2 <div wire:ignore wire:key="country-field"> 3 <select data-pharaonic="select2" data-property="country" data-options='{"placeholder": "Choose a country", "allowClear": true}'> 4 <option value="">Choose a country</option> 5 <option value="0">Zero Land</option> 6 <option value="1">Egypt</option> 7 <option value="2">Jordan</option> 8 </select> 9 </div>10 11 <div wire:ignore wire:key="tags-field">12 <select multiple data-pharaonic="select2" data-property="tags">13 <option value="a">A</option>14 <option value="b">B</option>15 </select>16 </div>17</div>data-options goes to Select2 as its configuration.
#3. The integration
1const NAMESPACE = '.pharaonicAssistant' 2 3// Select2 reports '' or [] for "nothing selected"; Livewire may hold null. 4const toWidget = (value, multiple) => { 5 if (multiple) { 6 return Array.isArray(value) ? value.map(String) : [] 7 } 8 9 return value === null || value === undefined ? '' : String(value)10}11 12const same = (left, right) => JSON.stringify(left) === JSON.stringify(right)13 14const select2 = {15 mount({ element, property, options, component }) {16 const $element = window.jQuery(element)17 const multiple = element.multiple18 19 $element.select2(options)20 21 if (property !== null) {22 const current = () => toWidget($element.val(), multiple)23 24 const apply = (value) => {25 const next = toWidget(value, multiple)26 27 // Equal values never touch the widget, so they cannot echo back.28 if (!same(next, current())) {29 // Notify only Select2's own namespace; our change handler stays silent.30 $element.val(next).trigger('change.select2')31 }32 }33 34 apply(component.get(property))35 36 $element.on(`change${NAMESPACE}`, () => {37 const value = current()38 39 if (!same(value, toWidget(component.get(property), multiple))) {40 component.set(property, value).catch((error) => console.error(error))41 }42 })43 44 component.watch(property, apply)45 }46 47 return () => {48 // Remove only our handlers, then restore the native <select>.49 $element.off(NAMESPACE)50 $element.select2('destroy')51 }52 },53}54 55const registerSelect2 = (assistant) => assistant.register('select2', select2)56 57if (window.Pharaonic?.assistant) {58 registerSelect2(window.Pharaonic.assistant)59} else {60 (window.PharaonicAssistantQueue ??= []).push(registerSelect2)61}#How the two directions stay in sync
- Initial value.
mountreads the property withget()and selects it. - User to server. Select2 fires
change; the handler sends the new value withset(). - Server to widget.
watch()reports the new value after each Livewire update, andapplyselects it. - No loop. A server value is applied with
trigger('change.select2'), which only notifies Select2, so the handler that writes to Livewire does not run. Both directions also compare values first: an equal value is neither applied nor sent. - No data loss. Values are compared as strings and arrays, never by truthiness, so
0,'', andnullstay distinct where Select2 can represent them:nulland''both show the empty option,0selects the option with value0.
Loop prevention belongs to the integration because only it knows when two values are the same for its widget. The Assistant gives it the events and the ownership, not a global "ignore equal writes" rule.
#Cleanup
The cleanup removes the handlers in the .pharaonicAssistant namespace only, so handlers your application added to the same element survive, then destroys Select2. The watcher ends by itself.
#Diagnostics
Failures that happen during automatic work, such as mounting an element after a Livewire update, cannot be thrown to you. They are dispatched as a pharaonic:assistant:error event on window and written to the console with console.error, in development and in production.
1window.addEventListener('pharaonic:assistant:error', (event) => {2 const { code, message, integration, componentId, element } = event.detail3 4 reportToYourErrorTracker(code, message)5})| Key | Value |
|---|---|
code | A stable code from the table below |
message | A readable explanation |
integration | The integration name, or null |
componentId | The component id involved, or null |
element | The element involved, or null |
Reports never contain property values or component state. When an error has a cause, such as the exception a mount threw, it is passed to console.error but not put in the event.
Calls you make yourself, such as register(), component.get(), or component.set(), throw or reject with an Error whose code property holds the same codes. scan() and destroy() never throw; what they find goes to the event.
#Error codes
| Code | Raised when | Channel |
|---|---|---|
invalid-registration | register() got an invalid name or definition | thrown |
registration-conflict | register() got a different definition for a taken name | thrown |
invalid-property | A property path is empty or invalid | thrown / rejected |
invalid-options | data-options is not a JSON object | event |
component-not-found | An explicit id matches no component, or an element has no component | event |
livewire-missing | Livewire was not on the page when it finished loading | event |
livewire-unsupported | A Livewire major this line does not support is on the page | event |
mount-failed | mount threw or returned something other than a function or undefined | event |
mount-async | mount returned a promise | event |
update-failed | update threw | event |
cleanup-failed | A cleanup callback threw | event |
watcher-failed | A watch callback threw | event |
queue-callback-failed | A queued registration callback threw, or the queue is not an array | event |
stale-context | A bridge method was used after its instance or component was destroyed | thrown / rejected |
unsupported-operation | The bridge cannot do this on the page's Livewire version; never raised on Livewire 2 | rejected |
request-failed | The Livewire request that carried a set() or call() failed | rejected |
#Missing integrations
An element whose data-pharaonic name is never registered is not an error, because integrations may register late. The Assistant warns once per unknown name with console.warn, which can also happen for an integration that registers a moment later, and mounts the elements as soon as the name is registered.
#API Reference
#Browser API
Available as window.Pharaonic.assistant and as named exports of dist/livewire-assistant.esm.js.
| Method | Description | Returns |
|---|---|---|
register(name, definition) | Registers an integration and mounts its elements when Livewire is ready. | An idempotent unregister() function |
start() | Connects to Livewire and installs its hooks once. Called automatically when the runtime loads; calling it again does nothing. | undefined |
scan(root = document) | Mounts every eligible element in root, including root itself. Does nothing until Livewire is ready. | undefined |
destroy(root = document) | Cleans up every instance in root, including root itself, and forgets failed mounts there. | undefined |
scan and destroy take an element or the document. Elements that are not in the page are never mounted.
#Integration definition
| Member | Signature | Required |
|---|---|---|
mount | (context) => (() => void) | undefined | yes |
update | (context) => void | no |
#Context
| Member | Type |
|---|---|
element | HTMLElement |
property | string | null |
options | object |
component | Component bridge |
onCleanup(callback) | Adds a disposer owned by the instance |
#Component bridge
| Member | Signature | Returns |
|---|---|---|
id | string | |
get | (path) | A copy of the value, or undefined |
set | (path, value, { live = true } = {}) | Promise<undefined> |
call | (method, ...args) | Promise of the method's return value |
watch | (path, callback(value, previousValue)) | An idempotent unsubscribe function |
#Globals
| Name | Purpose |
|---|---|
window.Pharaonic.assistant | The runtime |
window.PharaonicAssistantQueue | Registration callbacks queued before the runtime loads |
pharaonic:assistant:error | Error event dispatched on window |
#Laravel
| Name | Purpose |
|---|---|
@livewireAssistantScripts / @livewireAssistantScripts(['nonce' => $nonce]) | Renders the runtime script once per page |
pharaonic.livewire-assistant.script | Route name of GET /pharaonic/livewire-assistant/assistant.js |
Pharaonic\Livewire\Assistant\AssistantServiceProvider | Auto-discovered service provider |
#Compatibility
Every combination below installs with Composer and passes PHPStan and the PHP suite with lowest and highest dependencies. The real-browser suite (real Livewire 2 components driven in real browsers) runs on the lowest PHP of each Laravel version, with lowest and highest dependencies. CI also runs the browser suite in WebKit.
| Line | Livewire | Laravel | PHP | Browsers |
|---|---|---|---|---|
| 2.x | 2.12.7+ | 7.30+ | 8.0 | Chromium, Firefox |
| 2.x | 2.12.7+ | 8.75+ | 8.0, 8.1 | Chromium, Firefox |
| 2.x | 2.12.7+ | 9 | 8.0, 8.1, 8.2 | Chromium, Firefox |
| 2.x | 2.12.7+ | 10 | 8.1, 8.2, 8.3 | Chromium, Firefox |
Tested boundaries: Laravel 7.30.3, 8.75.0, 9.0.0, and 10.0.0 with Livewire 2.12.7 (lowest), and Laravel 7.30.7, 8.83.29, 9.52.22, and 10.50.3 with Livewire 2.12.8 (highest).
The 1.x line supports Livewire 1 on PHP 7.4; its documentation is the 1.x version of these docs.
#Why Livewire 2.12.7
CVE-2024-47823 (remote code execution through file uploads) affects every Livewire release before 2.12.7. Requiring 2.12.7 keeps it out of every application that installs this line, and every Livewire 2 application can update to it within its major.
#How it uses Livewire 2
- The runtime owns registration, discovery, ownership, the lifecycle, the bridge contract, and diagnostics.
- It follows Livewire 2 through its documented hooks (
component.initialized,element.initialized,element.updated,element.removed,message.sent,message.processed,message.failed), component teardown callbacks, and Turbolinks and Turbo events. set()with{ live: true }andcall()use Livewire's owncall(), so they settle with the request that carried them, andcall()resolves with the method's return value.
Livewire 2 has no public API for three things the bridge needs, so the runtime reads these internals and nothing else: the component store with its "initial render finished" flag, to find components and detect replaced ones and an already started Livewire; the component data and its waiting deferred writes, for get(); and the actions of each request, to settle a deferred set() with the request that carried it.
Laravel 7, 8, 9, and 10 no longer receive security fixes. This line supports them because Livewire 2 does, but your application still carries their advisories. Plan your upgrade to a maintained Laravel version, which means Livewire 3 or later.
#Examples
#1. A dependency-free input
An integration does not need jQuery. This one binds a plain input to a property, keeps it in step with server changes, and releases its listener through onCleanup instead of a returned function.
1const liveInput = { 2 mount({ element, property, component, onCleanup }) { 3 const apply = (value) => { 4 const next = value === null || value === undefined ? '' : String(value) 5 6 // Never overwrite what the user is still typing. 7 if (element !== document.activeElement && element.value !== next) { 8 element.value = next 9 }10 }11 12 const onInput = () => component.set(property, element.value)13 14 apply(component.get(property))15 element.addEventListener('input', onInput)16 onCleanup(() => element.removeEventListener('input', onInput))17 component.watch(property, apply)18 },19}20 21;(window.PharaonicAssistantQueue ??= []).push((assistant) => assistant.register('live-input', liveInput))#2. An action-only integration
Integrations that only trigger actions need no data-property.
1const confirmButton = { 2 mount({ element, options, component }) { 3 const onClick = () => { 4 if (window.confirm(options.message ?? 'Are you sure?')) { 5 component.call(options.action).catch((error) => console.error(error)) 6 } 7 } 8 9 element.addEventListener('click', onClick)10 11 return () => element.removeEventListener('click', onClick)12 },13}14 15;(window.PharaonicAssistantQueue ??= []).push((assistant) => assistant.register('confirm-button', confirmButton))1<div>2 <button type="button" data-pharaonic="confirm-button" data-options='{"action": "delete", "message": "Delete this post?"}'>3 Delete4 </button>5</div>#3. Options from PHP
Encode options with json_encode inside {{ }}. Blade escapes the quotes, and the browser decodes them again when it reads the attribute, so any value is safe.
1<div wire:ignore wire:key="tags-field">2 <select multiple data-pharaonic="select2" data-property="tags" data-options="{{ json_encode(['tags' => true, 'placeholder' => __('Add tags')]) }}"></select>3</div>On Laravel 7 and 8, @json splits its argument on commas. With an array literal such as @json(['tags' => true, 'placeholder' => '…']), it loses the flags that escape apostrophes, so a translated placeholder like "l'étiquette" ends a single-quoted attribute early, and longer arrays are cut off. Use @json($options) with a variable, or json_encode as above.
#4. Content added by your own script
1const panel = document.querySelector('#filters')2 3panel.insertAdjacentHTML('beforeend', templateHtml)4Pharaonic.assistant.scan(panel)5 6// Later, before removing it yourself:7Pharaonic.assistant.destroy(panel)8panel.remove()#5. Reporting failures
1window.addEventListener('pharaonic:assistant:error', ({ detail }) => {2 if (detail.code === 'mount-failed') {3 detail.element?.classList.add('widget-failed')4 }5})#Troubleshooting
#Nothing mounts and the console warns about an unknown integration
The data-pharaonic name was never registered. Check the spelling, and that your integration script is on the page. Registration may happen before or after the runtime loads; see Load Order.
#livewire-unsupported is reported
The page runs a Livewire major this line does not support. The 2.x line supports Livewire 2 only; use the 1.x line for Livewire 1.
#The runtime script returns 404
The web server answers .js URLs from public/ without asking Laravel. Let /pharaonic/livewire-assistant/assistant.js fall through to index.php, as Livewire's own /livewire/livewire.js must.
#Composer refuses to install Laravel
Composer blocks releases with security advisories when it installs or updates them, and no Laravel 7, 8, 9, or 10 release is free of them. See the warning in Installation for the packages to allow on each Laravel version.
#Composer refuses Livewire below 2.12.7
The 2.x line requires Livewire 2.12.7 or later, the first Livewire 2 release with the fix for CVE-2024-47823. Update Livewire within its major: composer update livewire/livewire.
#Typing in an input jumps back to older text
The integration applies every value watch() reports, including values from requests the user has already typed past. Leave the focused input alone, as in Basic Usage.
#livewire-missing is reported
Livewire's scripts were not on the page. Add @livewireScripts to the layout.
#component-not-found with a component id
No component has the id in data-component-id. The element is not bound to any other component; fix the id, or remove the attribute to use the nearest component.
#The widget disappears or duplicates after a request
The widget markup is not inside wire:ignore, so Livewire's morphing removes it. Wrap the element in wire:ignore.
#Toggling one widget removes a different one
Livewire compared the wrong ignored blocks. Give every ignored block that can appear or disappear a unique wire:key, and do not use an inner element's id as the key. See Targeting and the DOM.
#The widget does not follow server changes
Elements inside wire:ignore never receive new server HTML. Subscribe with component.watch(property, callback) and apply the value in the callback.
#Writes bounce back and forth
The integration applies server values in a way that fires its own change handler. Compare values before applying them, and refresh the widget without triggering your handler, as in the Select2 walkthrough.
#A deferred set() never resolves
set(..., { live: false }) resolves with the component's next request. If nothing sends one, call an action, for example component.call('$refresh'), or write with { live: true }.
#Navigating with Turbolinks or Turbo leaves widgets broken
Livewire 2 needs Livewire's livewire-turbolinks adapter after @livewireScripts. Without it, Livewire does not restart on visits, and the Assistant has no components to bind to.
#Coming from pharaonic/livewire-select2
pharaonic/livewire-select2 ships its own Select2 initialization script. To replace it on Livewire 2, remove that package and its script, keep jQuery and Select2, and use this package with the Select2 integration:
| livewire-select2 | Livewire Assistant |
|---|---|
wire:model on the select | data-property; the integration writes with set() |
wire:model.defer on the select | data-property, and set(property, value, { live: false }) in the integration |
data-component-id, required | data-component-id, optional |
data-placeholder, data-dir, data-language, data-tags, data-clear … | Select2 settings in data-options |
pharaonic.select2.init and pharaonic.select2.load browser events | Pharaonic.assistant.scan(element) |
| Widget changes sent to Livewire, one way | Two-way sync, including clearing and server changes |
Contributors
Amazing people who made this package possible
This package wouldn't exist without the contributions of our amazing community. A huge thank you to everyone who has helped improve Assistant!
Want to Contribute?
We welcome contributions from everyone! Whether it's bug fixes, new features, or documentation improvements - every contribution counts.