Livewire
Brok supports Livewire ^3.8.5 and
^4.4.1. Livewire binds to the primitives themselves. Native form controls
receive wire:* on the real control. A component whose value or open state
lives in Alpine exposes it through x-modelable, the contract that
wire:model (and x-model) attach to. No
Livewire-specific markup is rendered, so the same markup stays useful as plain Blade and HTML.
Note
Model timing
Use deferred binding for normal forms, debounced live binding for search, and blur binding for early
validation. Livewire 4.1 changed bare .blur: it delays client state but
does not send a request. Use wire:model.live.blur when a server request
must run on blur in both Livewire 3 and 4.
{{-- Deferred until the next action --}}
<brok:input wire:model="email" />
{{-- Debounced server requests --}}
<brok:input wire:model.live.debounce.300ms="search" />
{{-- Cross-major server request on blur --}}
<brok:input wire:model.live.blur="email" />
A bare wire:model.blur is still supported and forwarded unchanged. Its
network timing follows the installed Livewire version. Input, Textarea, Select, Checkbox, Radio, Switch,
Dropzone, and Input Group place the binding on their native control, not on a wrapper.
Alpine-held state
Some components keep their value in Alpine because no native control can carry it: the open state of
Dialog, Alert Dialog, Sheet, Drawer, Popover, Dropdown and Collapsible; the active Tabs value; and the
values of Rating, Tags Input, Input OTP, Select Advanced, Combobox, Date Picker, Date Range Picker,
Time Picker, Number Field, Slider, Slider Pips, Toggle Group, Calendar, Mini Calendar, Wheel Picker,
Angle Slider, Elastic Slider and Swap. Put wire:model on the component;
the same attribute works with x-model in a plain Alpine scope.
<brok:tabs wire:model="tab">
<brok:tabs.list>
<brok:tabs.trigger value="profile">Profile</brok:tabs.trigger>
<brok:tabs.trigger value="billing">Billing</brok:tabs.trigger>
</brok:tabs.list>
…
</brok:tabs>
<brok:rating wire:model.live="score" />
<brok:select-advanced wire:model="plan">
<brok:select-advanced.option value="starter">Starter</brok:select-advanced.option>
<brok:select-advanced.option value="pro">Pro</brok:select-advanced.option>
</brok:select-advanced>
<brok:tags-input wire:model="tags" />
<brok:date-picker wire:model="startsAt" />
<brok:slider wire:model.live="volume" />
The server property is the source of truth: on load it seeds the client state, and a value set on the
server (a reset, a preset) updates the control, its label and its view. A range slider, a range calendar
and the date range picker bind an array or a { start, end } pair; a
multiple combobox or toggle group binds an array. .live sends the request
on every change; a bare wire:model defers it to the next action.
A Choicebox group is the one native-control group that takes wire:model
on the group: <brok:choicebox.group wire:model="plan"> binds one value
for radio cards and an array for type="checkbox". Every modifier works,
.blur included (it fires when focus leaves the group), and a server reset
clears the checked card, also in a dialog that opens again. A single card can take
wire:model too; it reaches the card's native control.
Validation and nested fields
Property validation gives early feedback. A save method must call the complete
$this->validate() rule set before persistence.
#[Validate('required|email')]
public string $email = '';
public array $profile = ['email' => ''];
protected function rules(): array
{
return [
'email' => ['required', 'email'],
'profile.email' => ['required', 'email'],
];
}
public function save(): void
{
$validated = $this->validate();
// Persist only after all rules pass.
}
<form wire:submit="save">
<brok:field name="email" label="Email">
<brok:input type="email" wire:model.live.blur="email" />
</brok:field>
<brok:field name="profile.email" label="Profile email">
<brok:input type="email" wire:model.live.blur="profile.email" />
</brok:field>
<brok:button type="submit" wire-target="save" wire-loading-label="Saving…">
Save
</brok:button>
</form>
The Livewire and Laravel error key stays profile.email. Brok converts the
plain form name to profile[email]. Use
html-name only when the server needs another HTML name.
Loading and disabled states
Target the action that owns the request. Button adds wire:target, disables
itself during that request, and can show delayed progress and a loading label. It does not disable an
unrelated control.
<brok:button
wire:click="archive"
wire-target="archive"
wire-loading-label="Archiving…"
>
Archive
</brok:button>
<p wire:loading.delay wire:target="archive" role="status">
Archiving…
</p>
Search, pagination, and sorting
Reset pagination when a filter or page size changes. Use a server allowlist for every sort key. A field
name from wire:click is still untrusted input.
public string $search = '';
public function updatedSearch(): void
{
$this->resetPage();
}
<brok:input-group>
<brok:input-group.addon>
<brok:icon name="search" />
</brok:input-group.addon>
<brok:input-group.input
type="search"
wire:model.live.debounce.300ms="search"
placeholder="Search users…"
aria-label="Search users"
/>
<brok:input-group.clear />
</brok:input-group>
private const SORTABLE_FIELDS = ['name', 'email', 'created_at'];
#[Locked]
public string $sortField = 'name';
#[Locked]
public string $sortDirection = 'asc';
public function sortBy(string $field): void
{
abort_unless(in_array($field, self::SORTABLE_FIELDS, true), 400);
$this->sortDirection = $this->sortField === $field
? ($this->sortDirection === 'asc' ? 'desc' : 'asc')
: 'asc';
$this->sortField = $field;
$this->resetPage();
}
Livewire Data Table requires a stable non-empty row key. It uses id by
default or the configured row-key. It never falls back to a loop index.
Optimistic UI
Optimistic changes are suitable only for reversible, low-risk state. Keep the server authoritative and restore the previous state if the request fails. Do not use this pattern for payments, permissions, inventory, or deletion.
<button
type="button"
x-data="{ active: @js($favorite), busy: false }"
x-bind:aria-pressed="active"
x-bind:disabled="busy"
x-on:click="
const previous = active;
active = !active;
busy = true;
$wire.setFavorite(active)
.catch(() => { active = previous })
.finally(() => { busy = false });
"
>
Favorite
</button>
Focus and stable DOM
Keep the focused native control in the DOM. Do not change its element type, position, or key during a
request. Never key search by its current value. Give repeated stateful rows a database-backed
wire:key. Use wire:ignore only for a
descendant region that a third-party library fully owns.
The Input Group clear control dispatches a native input event and returns focus to the input, so a
debounced wire:model sees the change. The browser contract verifies that
focus remains after a debounced request and DOM morph.
Modal state
Set the initial state from Blade or bind the same state to Livewire. Dialog exposes its open state through
x-modelable, so Blade/Alpine use does not need Livewire.
{{-- Blade/Alpine --}}
<brok:dialog :open="$showWelcome">
<brok:dialog.content>…</brok:dialog.content>
</brok:dialog>
{{-- Bidirectional Livewire state --}}
<brok:dialog wire:model.live="confirmOpen">
<brok:dialog.content>
<brok:dialog.header>
<brok:dialog.title>Delete project</brok:dialog.title>
</brok:dialog.header>
…
<brok:dialog.close />
</brok:dialog.content>
</brok:dialog>
Focus enters the dialog, remains trapped, and returns to the prior control after close. Escape and the
backdrop close it. Closing writes false back to the server property, and a
server-side $set('confirmOpen', true) opens it.
Flash messages and toast events
public function save(): void
{
$this->validate();
// In-place update: raise the toast at once.
$this->dispatch('toast', message: 'Profile saved.', variant: 'success');
}
public function archive(): void
{
// Redirect: the toaster renders the flash on the next page.
session()->flash('status', 'Project archived.');
$this->redirectRoute('projects.index');
}
{{-- Once, in the layout --}}
<brok:toaster :flash="session('status')" flash-variant="success" />
The toaster listens for the toast window event (rename it with
event="notify"); a Livewire dispatch and a plain
CustomEvent both reach it, so no Livewire code is involved. Use the event
for an in-place update and the flash for a redirect. Do not use both for one action, or the toast shows
twice.
File uploads and progress
Dropzone forwards wire:model to the native file input and listens for the
standard Livewire upload lifecycle. It renders an accessible progress bar and reports failure or
cancellation. Client checks improve feedback; the server must validate again.
use Livewire\WithFileUploads;
public mixed $document = null;
public function saveUpload(): void
{
$validated = $this->validate([
'document' => ['required', 'file', 'mimes:pdf', 'max:10240'],
]);
$validated['document']->store('documents');
}
<brok:dropzone
name="document"
wire:model="document"
accept="application/pdf"
:max-size="10"
/>
<brok:button wire:click="saveUpload" wire-target="saveUpload">
Save upload
</brok:button>
Destructive confirmation
An alert dialog with wire-action asks first, prevents duplicate calls while busy, and restores trigger focus. The server
action must still resolve the record and authorize the operation.
<brok:alert-dialog wire-action="deleteUser" :wire-params="[$user->id]">
<brok:alert-dialog.trigger>Delete user</brok:alert-dialog.trigger>
<brok:alert-dialog.content>
<brok:alert-dialog.header>
<brok:alert-dialog.title>Delete this user?</brok:alert-dialog.title>
</brok:alert-dialog.header>
<brok:alert-dialog.footer>
<brok:alert-dialog.cancel>Cancel</brok:alert-dialog.cancel>
<brok:alert-dialog.action variant="destructive">Delete</brok:alert-dialog.action>
</brok:alert-dialog.footer>
</brok:alert-dialog.content>
</brok:alert-dialog>
public function deleteUser(int $userId): void
{
$user = User::query()->findOrFail($userId);
$this->authorize('delete', $user);
$user->delete();
}
Without Livewire, the confirmation prompt remains usable but the server call is inert. Use a normal POST/DELETE form when a complete non-Livewire destructive flow is required.
Reference components
| Component | Contract |
|---|---|
| input-group | Native model on the input, a clear control that keeps focus, debounce through the directive. |
| field | Label, hint, the shared error bag, and accessible error linkage. |
| dialog, tabs, and the Alpine-held state list above | Bidirectional wire:model through x-modelable; server state seeds and updates the control. |
| toaster | Server-dispatched toast events and flash input. |
| livewire-data-table | Search, pagination, allowlisted sorting, selection, stable row keys. |
| alert-dialog (wire-action) | Busy-safe destructive confirmation and focus return. |
| dropzone | Native file model plus upload progress, failure, and cancellation. |
The retired livewire-modal, livewire-tabs,
livewire-field, livewire-search,
livewire-confirm and livewire-toast adapters
are aliases: ui:add installs the primitive instead. Bind to the primitive.