Broadcasting
Broadcasting is how a notification reaches a browser that is already open. A job that finishes ten minutes after the request that started it has no response to ride back on, so the panel pushes the message over a websocket instead — the same toast a flash message produces, and the same bell a stored notification increments. This page covers the events, the channel, the toggles, and the client subscription. For getting a broadcaster running, see Reverb and Echo setup.
A minimal working example
<?php
use PandaPanel\Broadcasting\PanelNotification;
PanelNotification::dispatch($user, 'Your export is ready.', 'success');2
3
4
5
Every panel that user has open shows a green toast. Nothing was stored, and a user who was away sees nothing.
The two events
Both live on the same channel and broadcast under the same name, because to the frontend they are the same thing arriving: a message to show and, when it was persisted, a bell to increment. Two names would mean two subscriptions and two chances for them to disagree.
| Event | Raised by | Carries |
|---|---|---|
PandaPanel\Notifications\PanelNotificationSent | Notification::send() | the whole notification payload |
PandaPanel\Broadcasting\PanelNotification | you, directly | a message, a type, and an optional link |
PanelNotificationSent
namespace PandaPanel\Notifications;
final class PanelNotificationSent implements ShouldBroadcast
{
public function __construct(
public readonly Authenticatable $user,
public readonly array $payload,
) {}
public function broadcastOn(): array; // [new PrivateChannel(PanelNotification::channelFor($user))]
public function broadcastAs(): string; // 'panel.notification'
public function broadcastWith(): array;
}2
3
4
5
6
7
8
9
10
11
12
13
You rarely construct it. Notification::send() does:
if ($this->broadcast) {
event(new PanelNotificationSent($user, $this->toArray()));
}2
3
broadcastWith() is the notification payload with message added and persistent restated as a strict boolean — toArray() already carries persistent:
[
...$this->payload,
'message' => $this->payload['title'] ?? '', // the toast reads this
'persistent' => true, // the bell reads this
]2
3
4
5
message is sent alongside title rather than making either side guess, and persistent is present so the frontend knows whether the bell has a new row to fetch without asking.
PanelNotification
namespace PandaPanel\Broadcasting;
final class PanelNotification implements ShouldBroadcast
{
public function __construct(
public readonly Authenticatable $user,
public readonly string $message,
public readonly string $type = 'info', // 'success'|'info'|'warning'|'error'
public readonly ?string $url = null,
public readonly ?string $urlLabel = null,
) {}
public function broadcastOn(): array;
public static function channelFor(Authenticatable $user): string;
public function broadcastAs(): string; // 'panel.notification'
public function broadcastWith(): array; // {type, message, url, urlLabel}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
use PandaPanel\Broadcasting\PanelNotification;
// Dispatchable, so all four of these work.
PanelNotification::dispatch($user, 'Heads up');
event(new PanelNotification($user, 'Heads up'));
PanelNotification::dispatch($user, 'Import finished', 'warning');
PanelNotification::dispatch($user, 'Your export is ready.', 'success', $url, 'Download');2
3
4
5
6
7
This is the only event that carries url and urlLabel, which is what the client turns into a button on the toast. A finished export is a file, and a toast that only says so is a toast that makes somebody go looking for it.
The channel
public static function channelFor(Authenticatable $user): string
{
return 'App.Models.User.'.$user->getAuthIdentifier();
}2
3
4
Laravel's default channel name, unchanged, so a notification broadcast by anything else in the application arrives on the same one. It is built here rather than written out in Vue so the two cannot drift.
Both events wrap it in a PrivateChannel, so the name on the wire is private-App.Models.User.7. Subscribing to it requires the channel authorization callback in routes/channels.php — see Channel authorization.
Turning it on and off
use PandaPanel\Core\Panel;
$panel->broadcasting(false);2
3
| Method | Signature | Default |
|---|---|---|
broadcasting | broadcasting(bool $broadcasting = true): self | true |
hasBroadcasting | hasBroadcasting(): bool | true |
getBroadcastChannel | getBroadcastChannel(?Authenticatable $user): ?string | null when off or no user |
use PandaPanel\Core\Panel;
$panel = Panel::make('quiet')->broadcasting(false);
$panel->hasBroadcasting(); // false
$panel->getBroadcastChannel($user); // null2
3
4
5
6
Nothing connects until a page actually subscribes, and a page only subscribes when the server sent a channel — so a panel that turns this off costs no connection at all, rather than hiding one that was opened anyway.
Two questions, both of which must answer yes
A panel saying it wants realtime notifications is not the same as the application having something to deliver them with. SharePanelData asks both:
if ($panel === null || ! $panel->hasBroadcasting() || ! BroadcastSupport::isConfigured()) {
return ['enabled' => false, 'channel' => null];
}2
3
PandaPanel\Support\BroadcastSupport::isConfigured(): bool answers the second. Three things have to hold:
| Check | Fails when |
|---|---|
| A default connection is named | broadcasting.default is missing or empty — a fresh Laravel with no config/broadcasting.php |
| That connection exists and has a driver | broadcasting.connections.{default}.driver is missing — a typo, not a broadcaster |
| The driver reaches a browser | the driver is null or log — real drivers, and neither is something Echo can subscribe to |
use PandaPanel\Support\BroadcastSupport;
BroadcastSupport::isConfigured(); // bool2
3
Credentials are deliberately not checked: only the broadcaster can answer that, and a panel that refused to connect because it disliked the look of a key would be a worse failure than the one this replaces.
That failure is worth naming, because it is what this prevents: the server sent a channel, the client called echo() on it, @laravel/echo-vue threw "Echo has not been configured" from inside onMounted, and the aborted mount produced a dozen Slot "default" invoked outside of the render function warnings as Inertia swapped a layout that had never finished mounting. Nothing in that sequence names a broadcaster.
The shared prop
'broadcasting' => ['enabled' => true, 'channel' => 'App.Models.User.7'],| Field | Type | Value when off |
|---|---|---|
enabled | bool | false |
channel | string|null | null |
Null rather than an empty string, so the frontend has nothing to subscribe to rather than a channel it would be refused.
It is on the shared props, not on Panel::toSharedArray(). The answer depends on who is asking, so it belongs beside the request rather than in the panel definition the client caches.
import { usePanel } from '@/panel/composables/usePanel';
const { broadcasting } = usePanel();
broadcasting.value.enabled; // boolean
broadcasting.value.channel; // string | null2
3
4
5
6
The client subscription
resources/js/panel/composables/usePanelBroadcasting.ts, called once in PanelLayout.vue:
import { usePanelBroadcasting } from '@/panel/composables/usePanelBroadcasting';
usePanelBroadcasting();2
3
const EVENT = '.panel.notification'; // the dot marks a custom broadcastAs name
client.private(channel).listen(EVENT, (payload: unknown) => { /* … */ });2
3
What it does, in order:
- Returns immediately when
broadcasting.channelisnull. That is what makes a disabled panel cost nothing. - Narrows the payload. A payload arriving over a websocket has crossed the same boundary an HTTP response does, and is validated the same way rather than trusted:
messageandtypemust be strings, andtypemust be one ofsuccess,info,warning,error. Anything else is ignored. - Raises
windoweventpanel:notificationwhenpayload.persistent === true— before the toast, so the bell's count is right even if the toast is dismissed instantly. - Shows the toast through
vue-sonner, with an action button when the payload carried aurl. - Leaves the channel on unmount, but only if the subscription actually happened.
If configureEcho() was never called in the browser, echo() throws. The composable catches it, warns once in development, and the panel carries on without realtime notifications. The feature dies; the screen does not.
Queueing
Both events implement ShouldBroadcast, not ShouldBroadcastNow. Laravel pushes a BroadcastEvent job onto the default queue connection, so a worker has to be running for a toast to arrive. With QUEUE_CONNECTION=sync it happens inline, which is why it appears to work in local development without one. See Queued notifications.
Gotchas
dispatch()on aShouldBroadcastevent is not delivery. It is a queued job. A missing worker looks exactly like a missing broadcaster from the browser.- The event name needs its leading dot.
broadcastAs()returnspanel.notification; Echo requires.panel.notificationto skip its namespace prefixing. The composable already does this — it matters only if you subscribe yourself. - A guest gets no channel.
getBroadcastChannel(null)isnull, andSharePanelDatanever reaches it anyway. - The channel is the user's, not the panel's. Two panels open in two tabs subscribe to the same channel and both show the toast. That is intended: the notification is for the person, not the screen.
channelFor()usesgetAuthIdentifier(). A custom user model with a non-integer key still works, provided the callback inroutes/channels.phpcompares the same way.
See also
- Reverb and Echo setup — making a broadcaster exist
- Channel authorization — the rule that makes a private channel safe
- Toast notifications — what a broadcast turns into
- Notification center — the bell that
panel:notificationrefreshes - Queued notifications
- Server metadata to Vue
- Testing notifications