Communicate Between Livewire 4 Components with dispatch and #[On]

How to use the Livewire 4 dispatch event system: named parameters, the #[On] attribute, targeted ->to() calls, the Alpine bridge, and Pest assertions.

Steven Richardson
Steven Richardson
· 9 min read

A child component saves a post. A sibling counter needs to know. Props only travel downward, so there is no prop to reach for — and the shared cache key you are about to write is a worse idea than the event system you are avoiding. Livewire 4's dispatch event API covers this properly, but a few of its rules changed in v4 and the parameter binding trips people up in ways that look like the listener is broken.

Everything below is against Livewire 4.3, the current stable line.

Dispatch an event from a Livewire action#

Call $this->dispatch() from anywhere inside a component. The first argument is the event name; everything after it must be a named argument, and those names become the payload keys.

<?php // resources/views/components/post/⚡create.blade.php

use App\Models\Post;
use Livewire\Component;

new class extends Component
{
    public string $title = '';

    public function save(): void
    {
        $post = Post::create(['title' => $this->title]);

        // Named arguments become the event payload keys.
        $this->dispatch('post-created', postId: $post->id, title: $post->title);

        $this->title = '';
    }
};

The signature is dispatch($event, ...$params). Pass positionally and you get a payload keyed 0, 1, 2 — which no listener can bind to. This is the one hard break from Livewire 2's emit(), where positional was the only option.

The call does not fire immediately. Livewire pushes an Event object onto the component's store and serialises the whole queue into the response's dispatches effect. The browser replays them after the DOM has morphed.

Listen for the event with the #[On] attribute#

Put #[On] above any public method. Import it — use Livewire\Attributes\On; — because a missing import fails silently rather than erroring.

<?php // resources/views/components/⚡post-counter.blade.php

use Livewire\Attributes\On;
use Livewire\Component;

new class extends Component
{
    public int $count = 0;

    #[On('post-created')]
    public function increment(): void
    {
        $this->count++;
    }
};

The attribute is repeatable, so one method can serve several events:

#[On('post-created')]
#[On('post-updated')]
#[On('post-deleted')]
public function refreshStats(): void
{
    // Any post change invalidates the numbers.
}

Event names can interpolate component state, which scopes a listener to a single model. If $post->id is 3, this only ever reacts to post-updated.3:

public Post $post;

#[On('post-updated.{post.id}')]
public function refreshPost(): void
{
    $this->post->refresh();
}

Match named parameters to the listener signature#

This is where most "my listener isn't firing" reports actually come from. Livewire invokes your method with $method(...$params) where $params is the associative payload — PHP's spread on a string-keyed array means named arguments. Order is irrelevant. Spelling is not.

// Dispatched:
$this->dispatch('post-created', postId: 4, title: 'Hello');

// Works — names match, order differs. Fine.
#[On('post-created')]
public function handle(string $title, int $postId): void {}

// Throws "Unknown named parameter $post_id".
#[On('post-created')]
public function handle(int $post_id): void {}

Take only what you need by defaulting the rest, and give every parameter a default if the event is dispatched from more than one place with different payloads:

#[On('post-created')]
public function handle(int $postId, bool $silent = false): void
{
    // $silent is optional; callers that omit it still bind cleanly.
}

Treat those values as untrusted. The payload round-trips through the browser between dispatch and listener, so a postId arriving here is user input, not something your server chose. Authorize on the way in, and keep anything that must not change in locked properties that prevent client-side tampering.

Target one component with dispatch()->to()#

By default a dispatch bubbles from the component's root element up to window, so every listener on the page hears it. To reach exactly one component, chain ->to():

use App\Livewire\Dashboard;

$this->dispatch('post-created', postId: $post->id)
    ->to(component: Dashboard::class);

There is no $this->dispatchTo() in Livewire 4 — it does not exist in the source. If you are porting code or copying a Livewire 3 snippet, ->to(component: ...) is the replacement. Note the named argument: v3's to() took a bare string, v4's signature is to($component = null, $ref = null, $el = null, $self = null).

Those extra parameters are new in v4 and genuinely useful. ref: targets an element tagged with wire:ref, and el: takes a CSS selector scoped to the current component:

// Fires only into the element marked wire:ref="totals".
$this->dispatch('recalculate')->to(ref: 'totals');

// Fires into every .price-tag inside this component.
$this->dispatch('flash')->to(el: '.price-tag');

If several are set, precedence runs selfcomponentrefel. Set two and the lower one is ignored silently.

Keep an event inside its own component with ->self()#

Sometimes you want the event channel without the broadcast — a re-entrant action, or a component that is rendered many times on one page and must not trigger its siblings.

// Both forms are equivalent.
$this->dispatch('row-saved')->self();
$this->dispatch('row-saved')->to(self: true);

Without ->self(), rendering the same component in a loop means one row's dispatch hits every row. That is the single most common cause of a duplicated network request storm in a table of Livewire rows — worth checking before you reach for something like wire:poll for live dashboard metrics to paper over it.

Dispatch events from Blade and Alpine#

You do not need a server round trip to fire an event. $dispatch is available in any wire: expression:

<button wire:click="$dispatch('close-modal')">
    Cancel
</button>

<button wire:click="$dispatch('show-post-modal', { id: {{ $post->id }} })">
    Edit
</button>

The JavaScript side keeps the dispatchTo spelling that PHP dropped. On $wire you get $dispatch, $dispatchTo, $dispatchSelf, $dispatchRef and $dispatchEl:

<button wire:click="$dispatchTo('post-counter', 'post-created', { postId: {{ $post->id }} })">
    Publish
</button>

Inside a view-based component's <script> tag, this is bound to $wire, so the same magics are one word shorter:

<script>
    this.$dispatch('post-created', { postId: 4 })
    this.$dispatchSelf('reset')

    this.$on('post-created', (event) => {
        console.log(event.postId)
    })
</script>

Parent templates can also listen to a specific child without any attribute at all, forwarding payload values straight into a method:

<livewire:post.edit @saved="close($event.detail.postId)" />

Catch a Livewire event in Alpine with x-on#

Livewire events are real browser CustomEvents, so Alpine listens with no adapter. Because a plain dispatch bubbles up to window, add the .window modifier to hear it from anywhere on the page:

<div
    x-data="{ open: false }"
    x-on:show-post-modal.window="open = true"
    x-on:close-modal.window="open = false"
>
    <div x-show="open">
        <!-- Modal body -->
    </div>
</div>

Payload values live on $event.detail:

<div x-on:post-created.window="notify('New post: ' + $event.detail.title)"></div>

Alpine can dispatch back the other way with its own $dispatch magic, and a #[On] listener will pick it up:

<button x-on:click="$dispatch('post-created', { postId: 4 })">Fake it</button>

For a modal specifically, weigh this against binding state directly — I cover that trade-off in sharing modal state between Alpine and Livewire with wire:entangle. Events win when the two sides are unrelated components; entangling wins when it is one component and one piece of state.

Assert both ends with Pest#

Both directions are testable without a browser. assertDispatched() accepts the same named parameters you dispatched with:

use App\Livewire\Dashboard;
use Livewire\Livewire;

it('dispatches post-created with the new id', function () {
    Livewire::test('post.create')
        ->set('title', 'Hello')
        ->call('save')
        ->assertDispatched('post-created', postId: 1);
});

Parameter matching is partial — it compares only the keys you pass, so asserting on postId ignores a title you did not mention. For anything conditional, pass a closure instead and receive the name and full payload:

->assertDispatched('post-created', fn (string $name, array $params) => $params['postId'] > 0);

To test the listener, dispatch into the component under test:

it('increments the counter when a post is created', function () {
    Livewire::test('post-counter')
        ->assertSet('count', 0)
        ->dispatch('post-created', postId: 1)
        ->assertSet('count', 1);
});

assertNotDispatched() and assertDispatchedTo(Dashboard::class, 'post-created') round out the set — the second one verifies the ->to(component:) targeting actually resolved. If you are setting up a suite from scratch, start with testing Livewire 4 components with Pest and layer these assertions on top.

Debug a listener that never fires#

Five failure modes account for nearly all of them, and only the first is obvious.

A typo'd parameter name throws rather than passing null. Covered above — check the exception, not the listener registration.

Targeted dispatches do not bubble. ->self(), ->to(component:), ->to(ref:) and ->to(el:) all fire with bubbles: false. An x-on:my-event.window listener will never see them, because the event never reaches window. Use a plain dispatch() if JavaScript elsewhere on the page needs to hear it.

Lazy components register no listeners until they mount. Livewire skips the listener effect entirely while a lazy component is still mounting. Dispatch an event before that component has loaded and it is dropped, with no error. If a lazy sibling must not miss updates, either drop the lazy attribute or have the component pull state in mount() rather than waiting to be told.

Livewire.on() ignores foreign events. The global helper filters on an internal __livewire flag, so a hand-rolled window.dispatchEvent(new CustomEvent('post-created')) will not trigger it. Alpine's x-on and server-side #[On] listeners both will. Use x-on:post-created.window when the event originates outside Livewire.

Dispatching an unlistened event in a test throws. Livewire::test(...)->dispatch('whatever') raises EventHandlerDoesNotExist if that component has no matching #[On]. Useful once you know it — it means a green test genuinely proves the listener is wired.

Decide when not to use events#

Events are the right tool for loosely coupled components that should not know about each other. They are the wrong tool for a child calling a method on its direct parent — <button wire:click="$parent.showCreatePostForm()"> does that in one line with no listener to keep in sync. They are also wrong for parent-to-child data flow, which is what reactive props for parent-child updates exist for.

The honest rule I apply: if you can name the exact component on the other end and it is directly above or below you in the tree, skip the event. If you cannot — or there might be three listeners tomorrow — dispatch. From here, real-time chat with Livewire and Reverb shows the same #[On] attribute wired to broadcast events over WebSockets, which is where this pattern earns its keep.

FAQ#

How do I pass data between Livewire components?

Dispatch an event with named parameters from the source component and listen for it with the #[On] attribute on the destination. Livewire serialises the payload into the response, the browser replays it as a CustomEvent, and the listening component makes its own round trip to handle it. For direct parent-to-child data, use props or reactive props instead — they avoid the extra request entirely.

What replaced emit in Livewire 3 and 4?

emit() was replaced by dispatch() in Livewire 3, and Livewire 4 keeps that method unchanged. The important difference from Livewire 2 is that parameters are named rather than positional: $this->dispatch('post-created', postId: 4) instead of $this->emit('postCreated', 4). The $listeners array was also replaced by the #[On] attribute, although the array still works if you define getListeners().

How do I listen for a Livewire event in Alpine.js?

Use Alpine's x-on directive with the event name, adding the .window modifier so the listener catches events from any component on the page: x-on:post-created.window="...". Access the payload through $event.detail, so a postId parameter reads as $event.detail.postId. This only works for plain dispatches — events sent with ->self() or ->to() do not bubble to window.

What is the difference between dispatch and dispatchTo?

In PHP there is no dispatchTo() in Livewire 4; you chain ->to(component: Dashboard::class) onto dispatch() to reach a single component instead of broadcasting to the page. In JavaScript, $wire.$dispatchTo('component-name', 'event', {...}) does still exist and does the same job. The behavioural difference matters: a plain dispatch() bubbles to window, while a targeted one does not.

How do I test a dispatched Livewire event?

Call the action and chain assertDispatched('post-created', postId: 1), passing the same named parameters you dispatched with. Matching is partial, so you only assert on the keys you care about, and a closure as the second argument gives you the full payload for conditional checks. To test the receiving end, use ->dispatch('post-created', postId: 1) on the listening component and assert on the resulting state.

Why is my Livewire event listener not firing?

The most common cause is a parameter name mismatch between the dispatch and the listener signature, which throws an "Unknown named parameter" error rather than failing quietly. After that, check whether the event was sent with ->self() or ->to() — those do not bubble, so window-level listeners miss them — and whether the target component is lazy, since lazy components register no listeners until they finish mounting. Finally, confirm you imported Livewire\Attributes\On.

Steven Richardson
Steven Richardson

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