Broadcasting
Broadcasting is off by default — the plain events still fire either way. Turn it on to push changes live over Laravel broadcasting (Reverb, Pusher, Ably, …):
REALTIME_MESSAGES=trueWhen enabled, the Thread, Participant and Message models use Laravel’s BroadcastsEvents on create, update, delete and restore. A new thread is announced once its participants are in, so a private thread reaches every one of them. A soft delete — an unsend or a leave — fires both of Laravel’s trashed and deleted model events but goes out once, as trashed; a force delete goes out once, as deleted.
A thread broadcasts only its creation: renaming, archiving or deleting one is not broadcast. React to the ThreadRenamed and ThreadArchived events to push those yourself — or enable system messages, which posts a (broadcast) “renamed” message into the thread.
Channels
| Channel | Type | Carries |
|---|---|---|
messaging | public | messaging.thread.created for public threads. |
messaging.participant.{name}.{id} | private | messaging.thread.created for private and direct threads — once per participant: {id} is its key, {name} its lowercased class basename (e.g. user); {alias} keeps same-basename classes apart (see below). |
messaging.thread.{id} | private | Participant events (joined, read, left), message events (sent, updated, unsent, restored) and typing. |
Channel authorisation
The package registers no channel authorisation callbacks on its own. Opt in with Messages::channels()->register() — once, from routes/channels.php or a service provider’s boot() — or authorise the private channels yourself in routes/channels.php. Messages::channels() also returns the channel names to hand the client:
use RoundlyConsulting\Messages\Facades\Messages;
// routes/channels.php (or a service provider's boot()) — call it once
Messages::channels()->register(); // authenticate with the default guard
Messages::channels()->register(['web', 'admin']); // …or through these guards
// Hand the client the names to subscribe to — without the private- prefix,
// the way Echo.private() and routes/channels.php spell them:
Messages::channels()->participant($user); // 'messaging.participant.user.5' — new threads for $user
Messages::channels()->thread($thread); // ['messaging.thread.42'] — messages, reads, typing- Each thread channel (broadcasting.messages.channel and broadcasting.participants.channel — both messaging.thread.{id} by default) admits the thread’s current participants only.
- The per-participant channel admits only the model it belongs to — the authenticated user is the participating model.
- register($guards) authenticates the user through those guards; empty means the default guard.
- A thread template without {id} names no thread and gets no callback, and a channel on the deprecated {type} placeholder cannot be matched by a channel pattern — the broadcaster refuses both.
- It fails closed: an unknown thread, an id that cannot be a key of the configured key type (checked before any query), a user that is not a model, or a value your own route binding turned into an object are all refused.
- Under {name}, two classes sharing a basename still share a channel and are admitted to each other’s — use {alias}.
participant($model) and thread($thread) return names without the private- prefix, the way Echo.private() and routes/channels.php spell them; thread() returns a list — one name with the default config, two when you give messages and participants different channels. The same handle is on the injected manager, and register() runs the RegisterBroadcastChannels action:
use RoundlyConsulting\Messages\Actions\RegisterBroadcastChannels;
use RoundlyConsulting\Messages\MessagesManager;
app(MessagesManager::class)->channels()->participant($user); // the injected manager
app(RegisterBroadcastChannels::class)->execute(['web']); // the action behind register()Channel and event names
Every channel and event name is configurable:
'broadcasting' => [
'enabled' => env('REALTIME_MESSAGES', false),
'threads' => [
'public-channel' => 'messaging',
'per-participant-channel' => 'messaging.participant.{name}.{id}', // {name}, {alias}, {id} — {type} is deprecated
'events' => [
'created' => 'messaging.thread.created',
],
],
'participants' => [
'channel' => 'messaging.thread.{id}',
'events' => [
'created' => 'messaging.participant.joined',
'updated' => 'messaging.participant.read',
'trashed' => 'messaging.participant.left',
'restored' => 'messaging.participant.joined',
'deleted' => 'messaging.participant.left',
],
],
'typing' => [
'event' => 'messaging.participant.typing',
],
'messages' => [
'channel' => 'messaging.thread.{id}',
'events' => [
'created' => 'messaging.message.sent',
'updated' => 'messaging.message.updated',
'trashed' => 'messaging.message.unsent',
'restored' => 'messaging.message.restored',
'deleted' => 'messaging.message.unsent',
],
],
],Per-participant channel placeholders
The per-participant channel takes these placeholders: {id} is the participant’s key; {name} its lowercased class basename (App\Models\User → user); {alias} its morph alias when the type is one (and made of characters a channel segment can hold), otherwise a 16-character hash of the type. Without a morph map, two models sharing a basename — App\Models\User and App\Models\Admin\User — share a {name} channel and each hears the other’s thread.created announcements. {alias} keeps them apart, and no class name reaches the client:
// config/messages.php — 'broadcasting' => [ 'threads' => [ … ] ]
'per-participant-channel' => 'messaging.participant.{alias}.{id}',
// Under a morph map, {alias} is the alias itself:
Relation::morphMap(['diner' => User::class]);
Messages::channels()->participant($user); // 'messaging.participant.diner.5'
// Without one, it is the first 16 hex characters of the type's SHA-256 — so
// App\Models\User #5 and App\Models\Admin\User #5 get two different channels,
// and neither name contains a class name.Subscribe the client to the name Messages::channels()->participant($user) returns rather than building it yourself, so it follows the template. {type} — the full morph type, lowercased with \ as . — is deprecated: it puts the class name in a channel name the client sees, and register() cannot authorise it.
Upgrading to 2.1
2.1.0 adds no migration and changes no default — per-participant-channel stays messaging.participant.{name}.{id}. Switch it to messaging.participant.{alias}.{id} now: the next major makes that the default and removes {type}. Clients that subscribe to the name Messages::channels()->participant($user) returns keep working through the switch.
Payloads
- Thread — id, name, is_public, everyone_can_join.
- Participant — id, participant (participateAs()), read_at, joined_at, left_at.
- Message — id, sender (participateAs()), message (null once unsent), sent_at, updated_at, deleted_at.
- Typing — thread_id, participant (participateAs()).
Typing indicators
Broadcast a live typing indicator on the thread channel — it only broadcasts (never persists), respects broadcasting.enabled, and refuses anyone who is not a current participant with a ParticipationException, whether or not broadcasting is on:
use RoundlyConsulting\Messages\Facades\Messages;
Messages::thread($thread)->typing($user); // broadcasts ParticipantTyping — never persisted
$thread->typing($user); // the same, from the modelShow your open-source love
This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.
More ways to support, including cryptoBy donating, you agree to our donation terms.
Want this built into your product?
We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.