Livewire 4 Interceptors: Hooking Every Request Without Touching a Component

Livewire interceptors have three levels — action, message and request. Which to pick, the morph hooks, cancellation, and the gotchas that fire handlers twice.

Steven Richardson
Steven Richardson
· 12 min read

In Livewire 3 the global loading bar was three hacks in a trench coat: a livewire:navigating listener, a couple of Alpine events, and a comment apologising for the coverage gaps. The 419 handler lived in two files because nobody could remember which one did it. Re-initialising a date picker meant wire:ignore, $nextTick, and hope.

Livewire 4 has a real interceptor system with three levels. The first thing that goes wrong is picking the wrong one, because a batched update fires an action handler once per action and you end up with four spinners for one click.

Learn the difference between actions, messages and requests#

Everything else depends on this. An action is one method call on a component — save(), $refresh, delete(3). A message is one component's update round trip, and a single message may carry several actions. A request is one HTTP round trip, and a single request may carry messages for several components.

Open DevTools, click a button that calls two methods, and look at the payload. You get one request, containing one message, containing two calls:

{
  "components": [
    {
      "snapshot": "{\"data\":{\"count\":1},\"memo\":{\"id\":\"0qCY3ri9pzSSMIXPGg8F\",\"name\":\"counter\"}}",
      "updates": { "note": "hello" },
      "calls": [
        { "method": "increment", "params": [] },
        { "method": "save", "params": [] }
      ]
    }
  ]
}

Two entries in calls means two action interceptor firings. One entry in components means one message interceptor firing. One POST means one request interceptor firing. Pick the level that matches the thing you are counting.

Intercept a single action with $wire.intercept#

Action interceptors are the most granular level and the right tool for per-button behaviour. The callback receives the action plus a set of hook registrars, and you register whichever phases you care about.

<div>
    <button wire:click="publish">Publish</button>
</div>

<script>
    $wire.intercept('publish', ({ action, onSend, onSuccess, onError, onFinish }) => {
        let button = $wire.$el.querySelector('button')

        // Optimistic: flip the UI before the server has agreed.
        onSend(() => {
            button.disabled = true
            button.textContent = 'Publishing…'
        })

        onSuccess((result) => {
            // `result` is the return value of the PHP method.
            button.textContent = result?.slug ? 'Published' : 'Done'
        })

        // Revert on a server-side error (4xx/5xx with a Livewire body).
        onError(({ preventDefault }) => {
            preventDefault() // suppress Livewire's error modal
            button.textContent = 'Publish'
        })

        onFinish(() => {
            button.disabled = false
        })
    })
</script>

The full action hook set is onSend, onCancel, onSuccess, onError, onFailure and onFinish. onError is a server response you did not want; onFailure is the network dying before you got one. They are deliberately separate — treating a dropped connection as a validation failure is how you get misleading toasts. Calling action.cancel() from the interceptor body stops the action before it is sent at all, which is the whole wire:confirm pattern in four lines.

If you are using class-based components rather than single-file or multi-file ones, wrap the <script> in @script / @endscript so Livewire controls the execution timing.

Intercept a component message and use the morph hooks#

A message interceptor fires once per component update regardless of how many actions were batched into it. This is the level you want for anything that should happen once per round trip — a component-level overlay, a dirty-state reset, a third-party library re-init.

<script>
    $wire.interceptMessage(({ message, cancel, onSend, onSuccess, onFinish }) => {
        // message.actions is a Set — useful for logging what was batched.
        onSend(({ payload }) => {
            $wire.$el.classList.add('opacity-50')
        })

        onSuccess(({ payload, onSync, onMorphed, onRender }) => {
            onSync(() => {
                // Server state has been merged into the component.
            })

            onMorphed(() => {
                // The DOM is patched. Safe to touch real elements.
            })

            onRender(() => {
                // Next animation frame — measure layout here, not in onMorphed.
            })
        })

        onFinish(() => {
            $wire.$el.classList.remove('opacity-50')
        })
    })
</script>

Note where the morph hooks live. onSync, onEffect, onMorph, onMorphed and onRender are handed to you inside onSuccess — they are not properties of the interceptor argument. Destructure them at the top level and you get undefined and a silent no-op, which is the single most common mistake with this API.

The ordering for a successful message is fixed: onSuccess → onSync → onEffect → onMorph → onMorphed → onFinish → onRender. onMorph is for contributing asynchronous work that Livewire must await during the morph; almost everything you want belongs in onMorphed, which runs after component, island and slot morphs have all completed. Action promises resolve at the same time as onFinish, so anything in onMorphed is guaranteed to have run before a .then() on $wire.save().

There is also onSkipped, which fires instead of onSuccess when the server intentionally skips a message — an unchanged reactive child component, typically. No payload, no morph, no render, but onFinish still fires and action promises still resolve. Useful for telemetry; dangerous if you assumed onSuccess always runs.

Register a global request interceptor at livewire:init#

Cross-cutting concerns belong at the request level and belong in your app bundle, not in a component. Register inside the livewire:init event so the interceptor exists before Livewire starts processing anything — register later and your first few requests slip past unhooked.

// resources/js/livewire-interceptors.js

document.addEventListener('livewire:init', () => {
    Livewire.interceptRequest(({ request, onSend, onResponse, onError, onFailure, onFinish }) => {
        onSend(({ responsePromise }) => {
            // request.messages is a Set of the messages in this HTTP call.
        })

        onFinish(() => {
            // Fires on success, error, failure and cancellation.
        })
    })
})
// resources/js/app.js
import './livewire-interceptors'

The request hook set is wider than the other two because it is closest to the wire: onSend, onCancel, onResponse, onParsed, onSuccess, onError, onFailure, onStream, onRedirect, onDump and onFinish. onResponse gives you the Response before the body is read; onParsed gives you the raw body string. onRedirect and onDump both hand you a preventDefault(), which is how you keep a redirect from yanking the page out from under an open modal.

There is a component-scoped version too — $wire.interceptRequest(callback) fires only for requests that involve this component. Reach for it when you want request-level granularity without a global.

Build the four things that belong in a global interceptor#

Four concerns show up in every application I have shipped this on: a top-of-page progress bar, session-expiry handling, error reporting with component context, and a single retry on transient network failure. Here is the whole module.

// resources/js/livewire-interceptors.js
import * as Sentry from '@sentry/browser'

document.addEventListener('livewire:init', () => {
    let inFlight = 0
    let bar = document.getElementById('progress-bar')

    Livewire.interceptRequest(({ request, onSend, onError, onFailure, onFinish }) => {
        onSend(() => {
            if (++inFlight === 1) bar.classList.add('is-loading')
        })

        onError(({ response, preventDefault }) => {
            if (response.status === 419) {
                preventDefault() // stop Livewire's blank-page redirect
                document.dispatchEvent(new CustomEvent('session-expired'))
                return
            }

            // Attach the component names in this request to the Sentry event.
            let components = [...request.messages].map((m) => m.component.name)

            Sentry.captureMessage('Livewire request failed', {
                level: 'error',
                extra: { status: response.status, components },
            })
        })

        onFailure(({ error }) => {
            // Network-level: no response at all. Report and let the UI recover.
            Sentry.captureException(error, { tags: { layer: 'livewire-network' } })
        })

        onFinish(() => {
            if (--inFlight === 0) bar.classList.remove('is-loading')
        })
    })
})

Count in-flight requests rather than toggling a boolean. Two components updating concurrently produce two requests, and a boolean means the first one to finish hides a bar the second one still needs. The session-expired event is deliberate indirection — your layout listens for it and opens a real modal with a "log in again" link, instead of the browser confirm() the docs use for brevity.

The 419 branch has to call preventDefault(). Without it Livewire shows its own error modal or redirects, and your handler competes with it.

Cancel a message or an abandoned request#

Both messages and requests can be cancelled, which is what makes interceptors the clean home for an unsaved-changes guard and for killing superseded searches. The message interceptor hands you cancel() directly; the request interceptor exposes request.cancel().

<script>
    // Guard: block any update that would leave the form while it is dirty.
    $wire.interceptMessage('navigateAway', ({ cancel }) => {
        if ($wire.isDirty && ! confirm('You have unsaved changes. Discard them?')) {
            cancel()
        }
    })
</script>

The superseded-search case is the one worth internalising. Without cancellation, typing "lara" then "laravel" fires two requests, and if the first one is slower you render the results for "lara" over the top of the correct ones. Debouncing narrows the window but does not close it.

<script>
    let current = null

    $wire.interceptMessage('search', ({ message, cancel, onFinish }) => {
        if (current) current() // cancel the previous in-flight search
        current = cancel
        onFinish(() => { current = null })
    })
</script>

Pair this with the debounce settings in my guide to building a debounced live search input in Livewire 4 — debounce cuts the request count, cancellation removes the race that is left. For the dirty-state UI itself, the wire:dirty unsaved-changes indicator gives you the visual half of the guard above.

Re-initialise third-party JavaScript in onMorphed#

This is the pattern interceptors most obviously improve on. The Livewire 3 approach was wire:ignore plus a $nextTick, which worked until the ignored subtree needed to actually update.

Here is the old shape:

<!-- Livewire 3: fence it off and re-initialise on a guess. -->
<div wire:ignore>
    <input type="text" data-picker>
</div>

<script>
    Livewire.hook('morph.updated', () => {
        $nextTick(() => new Pikaday({ field: document.querySelector('[data-picker]') }))
    })
</script>

And the Livewire 4 replacement:

<div>
    <input type="text" data-picker>
</div>

@assets
<script src="https://cdn.jsdelivr.net/npm/pikaday/pikaday.js" defer></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/pikaday/css/pikaday.css">
@endassets

<script>
    let picker = null

    let mount = () => {
        let field = $wire.$el.querySelector('[data-picker]')
        if (! field) return

        picker?.destroy() // always tear down before rebuilding
        picker = new Pikaday({ field })
    }

    mount()

    $wire.interceptMessage(({ onSuccess }) => {
        onSuccess(({ onMorphed }) => onMorphed(mount))
    })
</script>

onMorphed fires after the DOM is settled, every time, with no guessing and no $nextTick. The destroy() call matters — rebuild without it and you leak a listener per round trip. For libraries with heavier teardown requirements, the full treatment is in my third-party JavaScript integration guide for Livewire 4.

Scope interceptors to islands and streams#

Islands update independently of their parent component, which changes which hooks you see. An island refresh still produces a message for the owning component, so interceptMessage fires — but onMorphed covers island and slot morphs as well as the component morph, so a single handler is enough for both cases. If you only want island updates, inspect the payload rather than registering a second interceptor.

Streaming adds onStream, available on both message and request interceptors. The message-level version is awaited, so asynchronous work inside it blocks the next chunk:

<script>
    $wire.interceptMessage(({ onStream }) => {
        onStream(async ({ json }) => {
            // Runs per chunk, before any morph. Awaited.
            console.log('chunk', json)
        })
    })
</script>

onStream fires per chunk while the response is still open, so it sits before onSuccess and the whole morph sequence. Do not put layout measurement in it.

Treat interceptors as UI plumbing, never authorisation#

Interceptors run in the browser. A user can open DevTools, unsubscribe yours, register their own, and watch every response the page receives — including responses for components they should have no influence over.

Nothing that matters for correctness lives here. No authorisation checks, no price calculation, no "this field is read-only" enforcement. Server-side policies and locked properties are what enforce those. An interceptor that cancels a destructive action is a nicety for honest users, not a control.

Avoid the five interceptor gotchas#

These are the ones that cost me real time. Four of the five produce no error at all, which is what makes them expensive.

Wrong level. An action interceptor used where a message interceptor was meant fires once per batched call — four spinners for one click. Count what you are reacting to: method calls are actions, round trips are messages.

Morph hooks destructured at the top. ({ onMorphed }) on the interceptor argument is undefined. They arrive inside onSuccess.

Registering too late. A global interceptor added after Livewire has initialised misses early requests. Register inside livewire:init.

Throwing inside an interceptor. An exception in a handler can swallow the update with nothing visible in the console. Wrap anything that touches a third-party SDK:

onMorphed(() => {
    try {
        mount()
    } catch (e) {
        console.error('[interceptor] mount failed', e)
    }
})

Using onFinish for success side effects. It fires on success, error, failure and cancellation. A toast in onFinish congratulates the user on a request that 500'd. Use onSuccess.

One more that is not a bug but bites in long-lived pages: component-scoped interceptors are cleaned up when the component is removed, global ones are not. Keep the unsubscribe function if you register a global from code that can run more than once.

let unsubscribe = Livewire.interceptRequest(callback)
// ...later
unsubscribe()

Verify the behaviour with a browser test#

Interceptors are JavaScript, so a Livewire component test will not see them. You need a real browser, which in a Laravel 13 app means Pest 4's Playwright-backed browser testing.

<?php

use function Pest\Browser\visit;

it('shows the progress bar during a Livewire request', function () {
    visit('/posts')
        ->click('@publish')
        ->assertSee('Publishing…')
        ->assertNoJavaScriptErrors();
});

Forcing a 419 to assert the session modal is harder than it looks — the usual approach is a route that invalidates the session, then triggering any component action. Full setup is in my guide to Pest 4 browser testing with Playwright. What you can assert in a standard component test is the server side of the action; the interceptor behaviour itself is browser-only.

Wrapping up#

Start with one global request interceptor covering the progress bar and 419 handling — that alone deletes more code than it adds. Move per-component loading state to interceptMessage, keep intercept for genuinely per-button behaviour, and put every third-party re-init in onMorphed.

If you are still on Livewire 3, the interceptor system is one of the better reasons to move; the path is in my Livewire 3 to 4 migration guide. And if your loading states are currently a patchwork, placeholder and skeleton loaders with islands pairs well with a global progress bar rather than competing with it.

FAQ#

What are interceptors in Livewire 4?

Interceptors are JavaScript callbacks that hook into Livewire's request lifecycle at three levels: action, message and HTTP request. You register them with $wire.intercept, $wire.interceptMessage and $wire.interceptRequest for a single component, or with Livewire.interceptAction, Livewire.interceptMessage and Livewire.interceptRequest for the whole application. Each returns an unsubscribe function.

What is the difference between intercept and interceptMessage in Livewire?

$wire.intercept fires once per action — one method call on the component. $wire.interceptMessage fires once per message, and a message is one component update that may batch several actions together. If a single click calls two methods, an action interceptor runs twice and a message interceptor runs once. Use the action level for per-button state and the message level for anything that should happen once per round trip.

How do I run JavaScript before and after every Livewire request?

Register a global request interceptor inside a livewire:init listener and use its onSend and onFinish hooks. Registering before Livewire initialises matters — an interceptor added later misses the requests that have already gone out. Keep a counter rather than a boolean in onSend and onFinish, because concurrent components produce concurrent requests.

How do I handle an expired session in Livewire 4?

Add a global request interceptor, check for a 419 status in its onError hook, and call preventDefault() to suppress Livewire's own error modal before showing your own. Dispatching a custom DOM event from the interceptor and letting your layout open a proper modal keeps the interceptor free of markup. Without preventDefault() your handler competes with Livewire's default behaviour.

How do I cancel a Livewire request?

A message interceptor receives a cancel() function directly on its argument, and a request interceptor exposes request.cancel(). Action interceptors use action.cancel() to stop an action before it is ever sent. Cancellation is how you implement an unsaved-changes guard, or abort a superseded search so a stale response cannot overwrite a newer one.

Where do I re-initialise a JS library after Livewire updates the DOM?

Use onMorphed, which you get from the onSuccess callback of a message interceptor. It runs after the component, island and slot morphs have all completed, and before onFinish and any action promise resolves. This replaces the Livewire 3 pattern of wire:ignore plus a $nextTick guess, and you should still call the library's own destroy() before rebuilding to avoid leaking listeners.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.