laravel-opcua · v4.4.x
Docs · Integrations

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

bash terminal — install Reverb
php artisan install:broadcasting --reverb

That installs laravel/reverb, sets up config/broadcasting.php, and adds JS scaffolding.

.env:

bash .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:

bash terminal — reverb
php artisan reverb:start

In production, run under Supervisor with --host=0.0.0.0.

The broadcast event

Create an event that implements ShouldBroadcast:

php app/Events/TagUpdated.php
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.

php app/Listeners/BroadcastTagUpdate.php
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:

php EventServiceProvider
use PhpOpcua\Client\Event\DataChangeReceived;

protected $listen = [
    DataChangeReceived::class => [
        BroadcastTagUpdate::class,
    ],
];

The browser side

Vite-managed JS:

text resources/js/bootstrap.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):

text resources/views/widgets/speed.blade.php
<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

text end-to-end
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:

php private channel
public function broadcastOn(): array
{
    return [
        new PrivateChannel('plc.operator.live'),
    ];
}

Authorise in routes/channels.php:

php 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:

php throttled broadcaster
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:

php batched broadcast
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):

php presence channel
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:

text presence client
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.

Documentation