Broadcasting
Pushing OPC UA value changes to the browser via Laravel Broadcasting. Reverb and Pusher setups, the listener-bridge pattern, and an end-to-end real-time tag widget.
OPC UA produces a stream of value changes. Laravel Broadcasting streams events to the browser. Wire them together and you get a real-time UI driven by physical equipment.
This page documents the pattern for bridging OPC UA events to
Laravel broadcasting — the package does not ship any
broadcasting wiring out of the box. You register a tiny Laravel
listener that translates PhpOpcua\Client\Event\DataChangeReceived
into your own ShouldBroadcast event.
Choose a driver
| Driver | When |
|---|---|
| Reverb | First-party, runs on your infra. Zero ops surface tax for most installs. The recommended default. |
| Pusher | Hosted, paid. Good if you'd rather not run a service. |
| Soketi | Self-hosted Pusher-compatible. Useful in air-gapped plants. |
| Log | Dev only — write to log instead of broadcasting. |
Most plant deployments use Reverb. The rest of this page assumes Reverb; the patterns are identical for Pusher.
Setting up Reverb
php artisan install:broadcasting --reverb
That installs laravel/reverb, sets up
config/broadcasting.php, and adds JS scaffolding.
.env:
BROADCAST_CONNECTION=reverb
REVERB_APP_ID=local
REVERB_APP_KEY=local
REVERB_APP_SECRET=local
REVERB_HOST=localhost
REVERB_PORT=8080
REVERB_SCHEME=http
VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
VITE_REVERB_HOST="${REVERB_HOST}"
VITE_REVERB_PORT="${REVERB_PORT}"
VITE_REVERB_SCHEME=http
Start Reverb in dev:
php artisan reverb:start
In production, run under Supervisor with --host=0.0.0.0.
The broadcast event
Create an event that implements ShouldBroadcast:
namespace App\Events;
use Illuminate\Broadcasting\Channel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class TagUpdated implements ShouldBroadcastNow
{
use Dispatchable, SerializesModels;
public function __construct(
public readonly string $nodeId,
public readonly mixed $value,
public readonly int $statusCode,
public readonly ?string $sourceAt,
) {}
public function broadcastOn(): array
{
return [
new Channel('plc.all'),
new Channel("plc.tag.{$this->nodeId}"),
];
}
public function broadcastWith(): array
{
return [
'node_id' => $this->nodeId,
'value' => $this->value,
'status' => $this->statusCode,
'source_at' => $this->sourceAt,
'good' => $this->statusCode === 0,
];
}
}
ShouldBroadcastNow skips the queue — sub-100 ms end-to-end.
For very high volume, use ShouldBroadcast (queued) instead.
The bridge listener
Listen to DataChangeReceived; emit your broadcast event. Keep
a clientHandle => nodeId map on the side because
DataChangeReceived only carries the handle, not the nodeId.
namespace App\Listeners;
use App\Events\TagUpdated;
use Illuminate\Contracts\Queue\ShouldQueue;
use PhpOpcua\Client\Event\DataChangeReceived;
class BroadcastTagUpdate implements ShouldQueue
{
public string $queue = 'broadcasts';
/** @var array<int, string> handle => nodeId map populated when subscribing */
private const HANDLE_TO_NODE = [
1 => 'ns=2;s=Speed',
2 => 'ns=2;s=Temperature',
];
public function handle(DataChangeReceived $event): void
{
$nodeId = self::HANDLE_TO_NODE[$event->clientHandle] ?? (string) $event->clientHandle;
broadcast(new TagUpdated(
nodeId: $nodeId,
value: $event->dataValue->getValue(),
statusCode: $event->dataValue->statusCode,
sourceAt: $event->dataValue->sourceTimestamp?->format('c'),
));
}
}
Register:
use PhpOpcua\Client\Event\DataChangeReceived;
protected $listen = [
DataChangeReceived::class => [
BroadcastTagUpdate::class,
],
];
The browser side
Vite-managed JS:
import Echo from 'laravel-echo';
import Pusher from 'pusher-js';
window.Pusher = Pusher;
window.Echo = new Echo({
broadcaster: 'reverb',
key: import.meta.env.VITE_REVERB_APP_KEY,
wsHost: import.meta.env.VITE_REVERB_HOST,
wsPort: import.meta.env.VITE_REVERB_PORT,
wssPort: import.meta.env.VITE_REVERB_PORT,
forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? 'https') === 'https',
enabledTransports: ['ws', 'wss'],
});
A tag widget (vanilla JS + Alpine):
<div x-data="{ value: null, ok: false }"
x-init="
Echo.channel('plc.tag.ns=2;s=Speed')
.listen('.App\\\\Events\\\\TagUpdated', (payload) => {
value = payload.value;
ok = payload.good;
});
"
class="rounded-lg border p-4">
<div class="text-sm text-gray-500">Speed</div>
<div class="mt-1 text-3xl font-bold" :class="ok ? '' : 'text-red-600'">
<span x-text="value !== null ? value.toFixed(2) : '—'"></span>
</div>
</div>
The widget updates in real time as TagUpdated events arrive on
plc.tag.ns=2;s=Speed.
End-to-end flow
PLC Daemon Laravel app Reverb Browser
│ │ │ │ │
│ value change │ │ │ │
├───────────────────►│ │ │ │
│ │ DataChangeReceived │ │ │
│ ├────────────────────►│ │ │
│ │ │ broadcast() │ │
│ │ ├───────────────────►│ │
│ │ │ │ TagUpdated │
│ │ │ ├──────────────────►│
│ │ │ │ │ UI updates
Typical latency: PLC → browser is 100-300 ms on a LAN with managed-mode auto-publish.
Private and presence channels
For operator-only data, use a private channel:
public function broadcastOn(): array
{
return [
new PrivateChannel('plc.operator.live'),
];
}
Authorise in routes/channels.php:
Broadcast::channel('plc.operator.live', function (User $user) {
return $user->hasRole('operator');
});
Now only authenticated users with the operator role can
subscribe. Useful for separating public dashboards from operator
controls.
Throttling — flooding the wire
A high-frequency tag (every 50 ms) is 1200 events/minute. The browser doesn't need that. Throttle in the listener:
use PhpOpcua\Client\Event\DataChangeReceived;
class BroadcastTagUpdate implements ShouldQueue
{
public function handle(DataChangeReceived $event): void
{
$cacheKey = "broadcast-throttle:{$event->clientHandle}";
if (Cache::has($cacheKey)) {
return;
}
Cache::put($cacheKey, true, milliseconds: 250);
broadcast(new TagUpdated(/* ... */));
}
}
4 broadcasts/sec maximum per tag. UI stays smooth, network unchoked.
Batching multi-tag updates
For a dashboard with 50 tags, fire one batch event rather than 50 individual ones:
class TagBatchUpdated implements ShouldBroadcastNow
{
public function __construct(public readonly array $updates) {}
public function broadcastOn(): Channel
{
return new Channel('plc.dashboard');
}
public function broadcastWith(): array
{
return ['updates' => $this->updates];
}
}
use PhpOpcua\Client\Event\DataChangeReceived;
class BatchBroadcaster implements ShouldQueue
{
public function handle(DataChangeReceived $event): void
{
// Append to a Redis list and a scheduled drainer broadcasts every 250ms
Redis::rpush('plc-batch', json_encode([
'client_handle' => $event->clientHandle,
'value' => $event->dataValue->getValue(),
'at' => $event->dataValue->sourceTimestamp?->format('c'),
]));
}
}
// Scheduled job to drain the buffer
$schedule->call(function () {
$items = Redis::lrange('plc-batch', 0, -1);
if (!empty($items)) {
Redis::del('plc-batch');
broadcast(new TagBatchUpdated(array_map('json_decode', $items)));
}
})->everySecond();
The browser receives one batch event per second containing all tag changes — efficient for high-tag-count dashboards.
Auth tokens and presence
For a presence channel (who's watching the dashboard):
public function broadcastOn(): array
{
return [new PresenceChannel('plc.operators-online')];
}
Broadcast::channel('plc.operators-online', function (User $user) {
return $user->hasRole('operator')
? ['id' => $user->id, 'name' => $user->name]
: null;
});
Browser side:
Echo.join('plc.operators-online')
.here((users) => { console.log('Operators online:', users); })
.joining((user) => { console.log(`${user.name} joined`); })
.leaving((user) => { console.log(`${user.name} left`); });
Production deployment
| Component | Where |
|---|---|
| Reverb | Supervisor / systemd, port 8080 |
| Reverb workers | --workers=4 for medium load |
| TLS proxy | nginx in front of Reverb on 443 |
Horizon for broadcasts queue (if not using ShouldBroadcastNow) |
Separate supervisor |
Reverb is single-process by default. For higher throughput, run multiple Reverb instances behind a sticky load balancer — but most plants are well under that threshold.
Where to read next
- Livewire — server-side reactive UI without hand-rolling JS.
- Recipes · Livewire real-time dashboard — the canonical end-to-end real-time UI.