Livewire 3: form failures and navigation listeners

Follow a tested Livewire 3 form through validation, authorization, a failed write and retry, then inspect listener cleanup across real browser navigation.
The exact boundary tested here
A Livewire form still needs an explicit server-side write boundary. Moving fields into a form object does not authorize the user, and replacing a page without a full reload does not remove document-level JavaScript listeners. These are separate decisions that the framework features make easier to express, not decisions the features make for your application.
This executable demonstration uses Livewire 3.8.10, Laravel 12.69.3 and PHP 8.3.35, with Playwright 1.63.0 and Chromium 153.0.8010.12. The versions and source hashes are recorded from actual execution. It tests this pinned combination, not the original 3.0.0 release or a Laravel 10 migration. The 3.x documentation describes the generation. Pinned package/source references identify the implementation used here.
The downloadable fixture supplies the model, policy, SQLite setup and Blade views missing from the two PHP classes alone. Its marked test controls select synthetic guard users and force a database failure. They are not a login implementation and must not be deployed. No real account, tenant membership or production environment was tested.
Group fields without hiding validation timing
PostForm keeps the title and body with their rules. The component explicitly calls its validate method during save. The example limits are application choices: required strings, a title no longer than 160 characters and a body no longer than 20000. I recommend a form object when related fields move together. It is not an authorization layer.
The browser run submitted empty fields and then over-length values, observed errors on both displayed field paths, and verified that neither request inserted a row. Ordinary wire:model bindings sent no update request during the explicit 500 ms observation window after typing and blurring. The values were sent with the later action. This is a bounded observation plus the documented deferred-binding mechanism, not a request-frequency benchmark.
Changing the title binding to wire:model.live caused the intended deferred-binding assertion to fail. Choose live updates when the interaction requires that different request timing. A modifier alone does not define your validation policy. This example deliberately validates at submit.
<?php
namespace App\Livewire\Forms;
use Livewire\Form;
class PostForm extends Form
{
public string $title = '';
public string $body = '';
protected function rules(): array
{
return [
'title' => ['required', 'string', 'max:160'],
'body' => ['required', 'string', 'max:20000'],
];
}
}
Authorize before writing, and return after a failed save
CreatePost checks for a guard user, calls the registered create policy and validates the form before assigning model fields. user_id comes from the guard, rather than a public form field. In the real HTTP action requests, the denied synthetic user received 403 and the guest received 401. Neither inserted a row. The permitted save stored author ID 1. Replacing that assignment with 999 failed the author assertion.
The failure test installs a SQLite BEFORE INSERT trigger that raises ABORT. The resulting QueryException reaches report(), adds a field error and returns before reset or success status. The server snapshot retained both values, and the browser still displayed them. The fixture recorded one report-handler notification without copying bindings into that receipt. It does not establish a production logging-redaction policy.
After removing the trigger, the same populated form was submitted again. One row was observed with the expected fields and author, both inputs reset, the previous error disappeared and the status read “Post created.”. Deliberately continuing after the failed write instead of returning failed the server-input-retention assertion.
This is a known rejected SQLite insert followed by a sequential retry. It is not proof of exactly-once creation, concurrent safety or recovery from an uncertain commit. A disabled button does not provide those guarantees. A workflow that needs duplicate protection still needs an application-level strategy and its own failure tests.
<?php
namespace App\Livewire;
use App\Livewire\Forms\PostForm;
use App\Models\Post;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\Gate;
use Livewire\Component;
class CreatePost extends Component
{
public PostForm $form;
public function save(): void
{
abort_unless(auth()->check(), 401);
Gate::authorize('create', Post::class);
$data = $this->form->validate();
try {
$post = new Post();
$post->title = $data['title'];
$post->body = $data['body'];
$post->user_id = auth()->id();
$post->save();
} catch (QueryException $exception) {
report($exception);
$this->addError('form.title', 'Save failed. Your text is still here.');
return;
}
$this->form->reset();
session()->flash('status', 'Post created.');
}
public function render()
{
return view('livewire.create-post');
}
}
Supply the view that makes the contract observable
The complete Blade view connects wire:submit to save and binds the form fields using their form prefix. Associated labels identify the inputs. Field errors and session status are rendered with Blade escaping. The Ping widget button belongs to the navigation experiment below, not to the post-creation operation.
The test uses real Laravel session and CSRF middleware over loopback HTTP. A request missing the CSRF token returned 419 and did not change the row count. The ordinary Livewire submissions used the page token successfully. Synthetic guard selection does not prove password login, session expiry or a real identity check.
<div data-post-widget>
<h1>Create a post</h1>
<form wire:submit="save">
<label for="title">Title</label>
<input id="title" wire:model="form.title" aria-describedby="title-error">
<p id="title-error" data-error="title">@error('form.title') {{ $message }} @enderror</p>
<label for="body">Body</label>
<textarea id="body" wire:model="form.body" aria-describedby="body-error"></textarea>
<p id="body-error" data-error="body">@error('form.body') {{ $message }} @enderror</p>
<button type="submit">Save post</button>
</form>
<p role="status">{{ session('status') }}</p>
<button type="button" data-widget-ping>Ping widget</button>
</div>
Own document listeners across navigation
The fixture layout links the form and an away page with wire:navigate. The instrumented script listens for livewire:navigated, which the 3.x documentation describes for initial load and completed navigation. Its document-ready counter is deliberately separate: the observed away, back, forward and revisit sequence kept the same document identity and did not repeat DOMContentLoaded.
On each completed navigation, cleanup aborts the previously owned listener before looking for a new widget. If the new page has no widget, nothing is installed. If it does, one AbortController owns both the button handler and document-level synthetic ping listener. The script is loaded once from the shared layout. Registering this global navigation handler repeatedly would introduce a different lifecycle problem.
The normal run observed one ping per mounted widget interaction. Dispatching a ping while away did not increment the counter. Back and revisit each restored one listener. Forward removed it again. Replacing cleanup with a no-op failed the away-listener assertion. These are synthetic events, not proof of analytics delivery or behavior for every third-party widget.
A fresh direct load initialized once in a new document. The test does not cover offline or cancelled navigation, cross-browser behavior, every component morph or screen-reader accessibility. Use the lifecycle appropriate to the actual widget rather than treating this small demonstration as a universal initializer.
window.fixtureLifecycle ??= {
id: crypto.randomUUID(),
domReady: 0, navigated: 0, mounts: 0, pings: 0,
};
document.addEventListener('DOMContentLoaded', () => {
window.fixtureLifecycle.domReady++;
});
let cleanup = () => {};
document.addEventListener('livewire:navigated', () => {
cleanup();
const state = window.fixtureLifecycle;
state.navigated++;
const widget = document.querySelector('[data-post-widget]');
if (!widget) return;
const controller = new AbortController();
const options = { signal: controller.signal };
document.addEventListener('fixture:ping', () => state.pings++, options);
const button = widget.querySelector('[data-widget-ping]');
button.addEventListener('click', () => {
document.dispatchEvent(new Event('fixture:ping'));
}, options);
cleanup = () => controller.abort();
state.mounts++;
});
Reproduce the checks and keep the claims bounded
From the downloaded fixture directory, install the locked browser dependency with npm ci, install its pinned Chromium using node node_modules/playwright-core/cli.js install chromium, then run node verify.mjs results.json with Docker available. The root README gives the extracted path and cache options. The runner builds the Composer lock in digest-pinned PHP images and uses fresh temporary storage for each case.
Forty HTTP/browser observations pass normally. Five actual altered implementations remove the policy check, choose the wrong author, clear failed input, enable live binding or leak a listener. Each must fail its intended assertion with the exact preceding observations. An unrelated exception or syntax error does not qualify. The runner compares hashes of files inside the app container with the corresponding sources.
The PHP container has no external network, host mount or published port. A host-loopback relay carries browser HTTP into container loopback without bypassing Laravel request handling. Effective isolation settings are inspected, and browser contexts block other origins. Task containers and temporary altered copies are removed after success or failure. Runtime images remain disposable caches.
The useful result is a specific contract: who may write, when validation runs, what survives this failed write and which listener belongs to the current page. The fixture demonstrates those boundaries under its stated conditions. It supplies no production history, performance improvement or general migration-success claim.
Reproduce the examples
Download the complete locked test app, exact published files, 40-observation browser runner and five actual defects. From the extracted fixture directory install the pinned browser dependency and run node verify.mjs results.json with Docker available. Synthetic users and failure controls only; never deploy the fixture controls.
Download the source and tests (.zip)
