Livewire Packagev2.0.0MIT License

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.

Livewire 2 line

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

Terminal
composer require pharaonic/livewire-assistant

Laravel discovers Pharaonic\Livewire\Assistant\AssistantServiceProvider automatically. There is nothing to publish and no configuration file.

Composer and unmaintained releases

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:

LaravelPackages to allow
7laravel/framework, league/commonmark, league/flysystem
8laravel/framework, league/flysystem
9 on PHP 8.0laravel/framework, symfony/http-foundation, symfony/mailer, symfony/mime, symfony/process, symfony/routing, symfony/yaml
9 on PHP 8.1+, 10laravel/framework
Terminal
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.

resources/views/layouts/app.blade.php
1<body>
2 {{ $slot }}
3 
4 @livewireScripts
5 @livewireAssistantScripts
6</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.

resources/views/layouts/app.blade.php
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:

RouteName
GET /pharaonic/livewire-assistant/assistant.jspharaonic.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 id matches 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:

resources/views/layouts/app.blade.php
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.

resources/js/app.js
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.

Installation Complete

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

resources/views/livewire/profile.blade.php
1<div>
2 <input type="text" value="{{ $name }}" data-pharaonic="live-input" data-property="name">
3</div>
AttributeMeaning
data-pharaonicThe integration name. Exactly one name per element; surrounding whitespace is trimmed. Required.
data-propertyA property path such as country_id or address.city. Optional; only integrations that bind a property need it.
data-component-idAn explicit Livewire component id. Optional; it takes precedence over the nearest component.
data-optionsA 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.

resources/js/integrations/live-input.js
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 = next
11 }
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.

Watchers clean up after themselves

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})
  • name starts with a letter or digit and continues with letters, digits, ., :, _ or -; surrounding whitespace is trimmed. Anything else throws invalid-registration.
  • definition.mount is required; definition.update is optional. Anything else throws invalid-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-conflict and 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.

OutcomeWhat happens
Returns a function or undefinedThe instance is kept.
ThrowsCleanups 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 promiseTreated as a failure (mount-async); cleanups registered with onCleanup run. Whatever the promise does later is up to the integration.
Returns anything elsemount-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 HTMLElement
3 property, // data-property, or null
4 options, // the parsed data-options object, {} when absent
5 component, // the component bridge, see Component Bridge
6 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.

MemberReturns
idThe 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') // undefined
3component.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, like wire:model. get() returns the new value once the round trip finished.
  • { live: false } defers the write, like wire: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() // idempotent

Watchers 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.

The bridge is not authorization

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.

resources/views/livewire/order.blade.php
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.

resources/views/livewire/order.blade.php
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 changed data-options;
  • server-side property changes reach the widget only through component.watch().
Key your ignored blocks

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 it
2Pharaonic.assistant.destroy(container) // cleans up the container and everything inside it

Both 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.

SituationResult
Livewire 2 loads before or after the AssistantWorks, no duplicate hooks
Livewire never loadslivewire-missing after the page load event
Another Livewire major is on the pagelivewire-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:

resources/js/integrations/select2.js
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

app/Http/Livewire/Profile.php
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

resources/views/livewire/profile.blade.php
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

resources/js/integrations/select2.js
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.multiple
18 
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. mount reads the property with get() and selects it.
  • User to server. Select2 fires change; the handler sends the new value with set().
  • Server to widget. watch() reports the new value after each Livewire update, and apply selects 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, '', and null stay distinct where Select2 can represent them: null and '' both show the empty option, 0 selects the option with value 0.

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.

resources/js/app.js
1window.addEventListener('pharaonic:assistant:error', (event) => {
2 const { code, message, integration, componentId, element } = event.detail
3 
4 reportToYourErrorTracker(code, message)
5})
KeyValue
codeA stable code from the table below
messageA readable explanation
integrationThe integration name, or null
componentIdThe component id involved, or null
elementThe 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

CodeRaised whenChannel
invalid-registrationregister() got an invalid name or definitionthrown
registration-conflictregister() got a different definition for a taken namethrown
invalid-propertyA property path is empty or invalidthrown / rejected
invalid-optionsdata-options is not a JSON objectevent
component-not-foundAn explicit id matches no component, or an element has no componentevent
livewire-missingLivewire was not on the page when it finished loadingevent
livewire-unsupportedA Livewire major this line does not support is on the pageevent
mount-failedmount threw or returned something other than a function or undefinedevent
mount-asyncmount returned a promiseevent
update-failedupdate threwevent
cleanup-failedA cleanup callback threwevent
watcher-failedA watch callback threwevent
queue-callback-failedA queued registration callback threw, or the queue is not an arrayevent
stale-contextA bridge method was used after its instance or component was destroyedthrown / rejected
unsupported-operationThe bridge cannot do this on the page's Livewire version; never raised on Livewire 2rejected
request-failedThe Livewire request that carried a set() or call() failedrejected

#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.

MethodDescriptionReturns
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

MemberSignatureRequired
mount(context) => (() => void) | undefinedyes
update(context) => voidno

#Context

MemberType
elementHTMLElement
propertystring | null
optionsobject
componentComponent bridge
onCleanup(callback)Adds a disposer owned by the instance

#Component bridge

MemberSignatureReturns
idstring
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

NamePurpose
window.Pharaonic.assistantThe runtime
window.PharaonicAssistantQueueRegistration callbacks queued before the runtime loads
pharaonic:assistant:errorError event dispatched on window

#Laravel

NamePurpose
@livewireAssistantScripts / @livewireAssistantScripts(['nonce' => $nonce])Renders the runtime script once per page
pharaonic.livewire-assistant.scriptRoute name of GET /pharaonic/livewire-assistant/assistant.js
Pharaonic\Livewire\Assistant\AssistantServiceProviderAuto-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.

LineLivewireLaravelPHPBrowsers
2.x2.12.7+7.30+8.0Chromium, Firefox
2.x2.12.7+8.75+8.0, 8.1Chromium, Firefox
2.x2.12.7+98.0, 8.1, 8.2Chromium, Firefox
2.x2.12.7+108.1, 8.2, 8.3Chromium, 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 } and call() use Livewire's own call(), so they settle with the request that carried them, and call() 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.

Unmaintained Laravel versions

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.

resources/js/integrations/live-input.js
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.

resources/js/integrations/confirm-button.js
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))
resources/views/livewire/post-row.blade.php
1<div>
2 <button type="button" data-pharaonic="confirm-button" data-options='{"action": "delete", "message": "Delete this post?"}'>
3 Delete
4 </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.

resources/views/livewire/tags.blade.php
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>
Not @json with an array literal

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

resources/js/app.js
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

resources/js/app.js
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 }.

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-select2Livewire Assistant
wire:model on the selectdata-property; the integration writes with set()
wire:model.defer on the selectdata-property, and set(property, value, { live: false }) in the integration
data-component-id, requireddata-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 eventsPharaonic.assistant.scan(element)
Widget changes sent to Livewire, one wayTwo-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!

Moamen Eltouny

@MoamenEltouny

17 contributions

Want to Contribute?

We welcome contributions from everyone! Whether it's bug fixes, new features, or documentation improvements - every contribution counts.