livewire/livewire: Exceptions from form lifecycle hooks are now handled properly
Contributed by billyonecan
Part of the livewire v4.4.7 release
One of the quieter but genuinely welcome fixes landing in the 4.x line: exceptions thrown from Livewire\Form lifecycle hooks now flow through Livewire's own exception pipeline instead of escaping to Laravel's global exception handler. Before this change, something as common as running validation inside a form's updated() hook could crash an entire update request with a confusing BadMethodCallException. PR #10376 fixes #10373, and this article covers what broke, how the fix works, and what — if anything — you need to change when upgrading from v3.8.10.
Why form hook exceptions escaped Livewire
When you bind a nested property like wire:model="form.title", Livewire doesn't just set the value on the form object. It wraps the form in its Wrapped helper, which intercepts property updates and fires the form's lifecycle hooks — updating(), updated(), and friends — much like it does for components.
The problem was in what happens when one of those hooks throws. Livewire dispatches exceptions through an internal exception event, and ComponentHookRegistry is responsible for forwarding that event to the hooks registered on the current component. But the registry only forwards the exception when the target is a Livewire\Component instance. A form object is not a component, so the exception fell straight through, bypassed every component-level hook (including the ones that convert ValidationException into error-bag entries), and bubbled up to Laravel's global handler.
For most exceptions that means an unhandled 500. For ValidationException it's worse in practice: Laravel's default rendering path tries to build a redirect response with flashed input (Redirector::withInput()), which throws a BadMethodCallException in the middle of a Livewire update. So a perfectly reasonable live-validation setup would take down the whole request.
What the fix does
The exception listener in ComponentHookRegistry now recognizes form objects and swaps in the component that owns them before dispatching:
on('exception', function ($target, $e, $stopPropagation) {
if ($target instanceof \Livewire\Features\SupportFormObjects\Form) {
$target = $target->getComponent();
}
if ($target instanceof \Livewire\Component) {
static::proxyCallToHooks($target, 'callException')($e, $stopPropagation);
}
});
Every form object knows its owning component via getComponent(), so the exception is now dispatched against the component, letting the usual exception hooks run — most importantly the validation handling that catches ValidationException and merges its errors into the error bag. The fix covers both subclasses of the user-facing Livewire\Form and forms extending the lower-level base form class directly; new tests cover validation inside hooks, validateOnly() inside updated(), and both form base classes.
The PR author notes this is an explicit special case, and that a more general abstraction for mapping wrapped targets to their exception target might be preferable — worth knowing if you're reading the internals, but functionally it's exactly what you want.
What it looks like in practice
This is the classic live-validation pattern that used to break:
use Livewire\Form;
class PostForm extends Form
{
public string $title = '';
public function rules()
{
return ['title' => 'required|min:3'];
}
public function updated(string $property): void
{
// Runs on every live update of a bound property
$this->validateOnly($property);
}
}
<input type="text" wire:model.live="form.title">
@error('form.title')
<p class="text-red-600">{{ $message }}</p>
@enderror
On earlier releases such as v3.8.10, clearing that input would fire updated(), validateOnly() would throw a ValidationException, the exception would escape to the global handler, and the request would die with a BadMethodCallException from Redirector::withInput(). After upgrading, the same code simply works: the exception is captured, form.title lands in the error bag, and the template re-renders with the message.
Upgrade impact
There are no API changes, new config options, or deprecations here — it's a pure routing fix inside the exception pipeline. The impact is behavioral, and almost entirely in the good direction:
- If you hit this bug, the fix just works. Requests that crashed with a 500 now complete with validation errors attached.
- If you worked around it, you can clean up. Common workarounds included moving validation out of form hooks into component methods, wrapping
$this->validate()in a try/catch inside the hook, or manually setting errors instead of throwing. All of those can be replaced by throwingValidationException(directly or viavalidate()/validateOnly()) and letting Livewire handle it. - If you somehow depended on these exceptions reaching Laravel's global handler, note that they no longer will in the normal case — component-level handling gets first crack at them, which matches how exceptions thrown from component lifecycle hooks already behave.
Takeaways
- Exceptions thrown inside
Livewire\Formlifecycle hooks (updating(),updated(), etc.) are now dispatched against the owning component instead of escaping to Laravel's global handler. ValidationExceptionthrown in a form hook no longer crashes the update with aBadMethodCallExceptionfromRedirector::withInput()— its errors land in the error bag and the request completes normally.- The fix applies to both
Livewire\Formsubclasses and forms extending the baseSupportFormObjects\Formclass. - Validating inside form hooks — including
validateOnly()for live validation — is now safe and fully supported. - No public API or configuration changes; if you carried a workaround for this bug, you can remove it when upgrading from v3.8.10.