Skip to content

Laravel Broadcasting with Mercure

Laravel Broadcasting publishes application events to clients. Laravel Echo receives them in the browser. This guide uses Laravel's built-in Mercure driver.

Keep Laravel's broadcasting API and choose where your real-time service runs. Mercure Cloud is the managed option; Mercure Enterprise runs on your infrastructure. Both work with Laravel's native Mercure driver and Echo.

#Setting up

In a Laravel release that supports the Mercure installer, run:

php artisan install:broadcasting --mercure

#Configuring the hub

Configure Laravel's MERCURE_URL, MERCURE_PUBLIC_URL, and MERCURE_JWT_SECRET. The hub must trust the same signing secret and issuer. In the configuration below, tokens use https://app.example.com as iss and the public hub URL as aud. Match these to your Laravel claims configuration (the issuer defaults to APP_URL).

mercure {
    issuer https://app.example.com {
        publisher {
            jwt {env.MERCURE_JWT_SECRET}
        }
        subscriber {
            jwt {env.MERCURE_JWT_SECRET}
        }
    }
    resource_identifier https://hub.example.com/.well-known/mercure

    # Publish presence events
    subscriptions

    # Required for whispers
    publish_origins https://app.example.com

    # Only if your hub and your app are not on the same origin
    cors_origins https://app.example.com
}

#Our first broadcast event

Create an event implementing Illuminate\Contracts\Broadcasting\ShouldBroadcast. Run a queue worker for queued broadcasts.

<?php

// app/Events/MessageSent.php

namespace App\Events;

use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;

class MessageSent implements ShouldBroadcast
{
    use Dispatchable, InteractsWithSockets;

    public function __construct(
        public string $content,
        public string $author,
    ) {}

    public function broadcastOn(): Channel
    {
        return new Channel('chat');
    }

    public function broadcastAs(): string
    {
        return 'message.sent';
    }

    /**
     * @return array<string, string>
     */
    public function broadcastWith(): array
    {
        return [
            'content' => $this->content,
            'author' => $this->author,
        ];
    }
}

#Send the event from a controller

Dispatch the event from an authenticated route with the broadcast() helper:

<?php

// app/Http/Controllers/MessageController.php

namespace App\Http\Controllers;

use App\Events\MessageSent;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class MessageController extends Controller
{
    public function store(Request $request): JsonResponse
    {
        $validated = $request->validate([
            'content' => ['required', 'string', 'max:500'],
        ]);

        broadcast(new MessageSent($validated['content'], $request->user()->name))
            ->toOthers();

        return response()->json(['status' => 'sent']);
    }
}

toOthers() excludes the originating Echo connection when the request includes its X-Socket-ID. Other tabs belonging to the same user can still receive the event.

#Private channel

Import Illuminate\Broadcasting\PrivateChannel in the event class and add a roomId property. Replace broadcastOn() with the method below. Authorize members in routes/channels.php.

public function broadcastOn(): Channel
{
    return new PrivateChannel('chat.' . $this->roomId); // Becomes "private-chat.1" on the hub
}
<?php

// routes/channels.php

use App\Models\User;
use Illuminate\Support\Facades\Broadcast;

Broadcast::channel('chat.{roomId}', function (User $user, int $roomId) {
    return $user->rooms()->whereKey($roomId)->exists(); // true = authorized
});

With Mercure, each private update is published as a private update on the hub. Only subscribers whose token contains this channel can receive it.

#Presence channel

Import Illuminate\Broadcasting\PresenceChannel and add a groupId property. The authorization callback returns member data for presence listeners.

public function broadcastOn(): Channel
{
    return new PresenceChannel('group.' . $this->groupId); // Becomes "presence-group.1" on the hub
}
// routes/channels.php
Broadcast::channel('group.{groupId}', function (User $user, int $groupId) {
    if (! $user->groups()->whereKey($groupId)->exists()) {
        return false;
    }

    return ['id' => $user->id, 'name' => $user->name]; // Data shared with the other members
});

#End-to-end encryption

An encrypted channel hides the event name, payload, and socket ID from the hub. Routing metadata remains visible. Configure Laravel's MERCURE_ENCRYPTION_KEY as described in the broadcasting documentation.

public function broadcastOn(): Channel
{
    return new PrivateEncryptedChannel('chat.' . $this->roomId); // Becomes "private-encrypted-chat.1" on the hub
}

Import Illuminate\Broadcasting\PrivateEncryptedChannel and define roomId on the event. It uses the same authorization as PrivateChannel, so our chat.{roomId} callback works for both.

#Laravel Echo

Laravel Echo listens to the events sent by Laravel. The install command already configures it with the Mercure broadcaster.

// resources/js/echo.js
import Echo from "laravel-echo";

window.Echo = new Echo({
  broadcaster: "mercure",
  host: import.meta.env.VITE_MERCURE_HUB_URL, // If empty, Echo uses /.well-known/mercure on the current domain
});

#Listen to a public channel

window.Echo.channel("chat").listen(".message.sent", (event) => {
  // The dot is needed because we used broadcastAs()
  console.log(`${event.author}: ${event.content}`);
});

Echo requests a Mercure token even for public channels:

POST /broadcasting/auth
{"channel_names":["chat"]}

HTTP/1.1 200 OK
Set-Cookie: __Secure-mercure_access_token=<token>; Path=/.well-known/mercure; Domain=example.com; Secure; HttpOnly; SameSite=Strict
{"channel_names":[{"name":"chat"}],"expires_in":300,"topic_prefix":"https://laravel.alt/echo/","client_events":true}

Then it subscribes to the hub, the channel is now a Mercure topic:

GET https://example.com/.well-known/mercure?match=https%3A%2F%2Flaravel.alt%2Fecho%2Fchannel%2Fchat

And when our controller broadcasts the event, the hub sends:

id: <event-id>
data: {"channels":["chat"],"event":"message.sent","payload":{"content":"Hello!","author":"Bob"}}

#Listen to a private channel

window.Echo.private("chat.1")
  .listen(".message.sent", (event) => {
    console.log(`${event.author}: ${event.content}`);
  })
  .error((error) => {
    // Called if the user is not authorized, or if the connection fails
    console.error(error);
  });

If the user is not authorized, the auth request still succeeds for the other channels but this one is flagged as denied:

{
  "channel_names": [{ "name": "private-chat.1", "denied": true }],
  "expires_in": 300,
  "topic_prefix": "https://laravel.alt/echo/",
  "client_events": true
}

#Next steps