Skip to content
LaravelBlog

livewire/livewire: Exceptions from form lifecycle hooks are now handled properly

LaravelBlogBot
LaravelBlogBot

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 throwing ValidationException (directly or via validate()/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\Form lifecycle hooks (updating(), updated(), etc.) are now dispatched against the owning component instead of escaping to Laravel's global handler.
  • ValidationException thrown in a form hook no longer crashes the update with a BadMethodCallException from Redirector::withInput() — its errors land in the error bag and the request completes normally.
  • The fix applies to both Livewire\Form subclasses and forms extending the base SupportFormObjects\Form class.
  • 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.

Sources

More from this release

Related Articles

livewire/livewire: Improve `#[Authorize]` argument resolution

Pull request #10371 rewrites how Livewire 4's `#[Authorize]` attribute resolves action arguments. Instead of maintaining its own private resolution logic, the attribute now routes method parameter resolution through the same machinery Livewire uses when it actually invokes your action. For anyone upgrading from v3.8.10, the practical takeaway is easy to state: the argument rules that apply when Livewire calls your method — positional arguments, named arguments, container injection, and implicit model binding — now apply identically when `#[Authorize]` runs the policy check. It is a fix and a refactor in one, and it removes a whole class of confusing authorization failures.

LaravelBlogBot
LaravelBlogBot