Data events
DataChangeReceived — the most common subscription event. Field reference, listener patterns, persistence, broadcast, and the rules for handling thousands per second without melting the worker.
PhpOpcua\Client\Event\DataChangeReceived fires every time a publish
response carries a monitored-item data change. In a typical real-time
UI it is the event that drives everything.
The fields shown below are the real fields on the event object.
Note
Publish-driven. This event fires whenever a publish response
carries a notification. In managed mode with auto-publish, the
daemon drives the publish loop for you. In direct mode, your code
must call Opcua::publish(...) to receive notifications.
Field reference
namespace PhpOpcua\Client\Event;
final class DataChangeReceived
{
public function __construct(
public OpcUaClientInterface $client,
public int $subscriptionId,
public int $sequenceNumber,
public int $clientHandle, // matches the handle you set on createMonitoredItems()
public DataValue $dataValue,
) {}
}
DataValue (from opcua-client):
| Accessor | Returns | Meaning |
|---|---|---|
getValue() |
mixed | The decoded value (the underlying Variant's value) |
$dv->statusCode |
int | 0 = good |
$dv->sourceTimestamp |
?DateTimeImmutable |
When the device produced the value |
$dv->serverTimestamp |
?DateTimeImmutable |
When the OPC UA server timestamped it |
DataValue::$valueis private — always usegetValue(). There is no$dv->typeor$dv->dimensionsfield; those concepts live on the underlyingVariant(which is wrapped, not exposed).
The event does not carry the nodeId directly — only the
clientHandleyou assigned at item-creation time. Keep your ownclientHandle => nodeIdmap (or set the handle to a hash of the nodeId).
Simple listener — log
use PhpOpcua\Client\Event\DataChangeReceived;
class LogDataChanges
{
public function handle(DataChangeReceived $event): void
{
Log::channel('plc-data')->info(
"handle={$event->clientHandle} = " . var_export($event->dataValue->getValue(), true),
[
'status' => $event->dataValue->statusCode,
'sub' => $event->subscriptionId,
],
);
}
}
Persistence — Eloquent
use Illuminate\Contracts\Queue\ShouldQueue;
use PhpOpcua\Client\Event\DataChangeReceived;
class StoreReadings implements ShouldQueue
{
public string $queue = 'opcua-data';
public function handle(DataChangeReceived $event): void
{
if ($event->dataValue->statusCode !== 0) {
return; // skip bad readings
}
PlcReading::create([
'client_handle' => $event->clientHandle,
'value' => $event->dataValue->getValue(),
'source_at' => $event->dataValue->sourceTimestamp,
'server_at' => $event->dataValue->serverTimestamp,
]);
}
}
ShouldQueue is mandatory for any persistence listener on a
high-frequency subscription — see Queued listeners.
Beware serialisation.
DataChangeReceived::$clientis a live client object that does not serialise cleanly through Laravel's queue. When implementingShouldQueue, copy the primitive fields you need (handle, value, timestamps) into a job constructor and dispatch that job from a non-queued listener — or use Laravel's__sleep()/__serialize()machinery to drop$clientbefore the listener is serialised. The safest pattern is a tiny synchronous listener that dispatches an explicit job:
class FanOutDataChange
{
public function handle(DataChangeReceived $event): void
{
StoreReadingJob::dispatch(
$event->clientHandle,
$event->dataValue->getValue(),
$event->dataValue->statusCode,
$event->dataValue->sourceTimestamp,
);
}
}
Broadcasting to the UI
A two-step bridge — a tiny synchronous listener fires a separate broadcast event:
use Illuminate\Broadcasting\Channel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use PhpOpcua\Client\Event\DataChangeReceived;
class TagUpdated implements ShouldBroadcastNow
{
public function __construct(
public readonly int $clientHandle,
public readonly mixed $value,
public readonly ?string $sourceAt,
) {}
public function broadcastOn(): array
{
return [new Channel("plc.handle.{$this->clientHandle}"), new Channel('plc.all')];
}
}
class BroadcastTagUpdate
{
public function handle(DataChangeReceived $event): void
{
broadcast(new TagUpdated(
clientHandle: $event->clientHandle,
value: $event->dataValue->getValue(),
sourceAt: $event->dataValue->sourceTimestamp?->format('c'),
));
}
}
The browser subscribes to plc.all for a dashboard or to
plc.handle.<n> for a single-tag widget.
ShouldBroadcastNow skips the broadcast queue — sub-100 ms
end-to-end. For higher volumes, use ShouldBroadcast (which queues)
plus a dedicated broadcasts worker.
See Integrations · Broadcasting.
Caching the latest value
A pattern that pairs well with broadcasting — let any reader query the latest value cheaply:
use Illuminate\Contracts\Queue\ShouldQueue;
use PhpOpcua\Client\Event\DataChangeReceived;
class CacheLatestValue implements ShouldQueue
{
public string $queue = 'opcua-cache';
public function handle(DataChangeReceived $event): void
{
Cache::put(
"plc:latest:{$event->clientHandle}",
[
'value' => $event->dataValue->getValue(),
'status' => $event->dataValue->statusCode,
'at' => $event->dataValue->sourceTimestamp?->format('c'),
],
minutes: 5,
);
}
}
…then in a controller:
Route::get('/tags/{handle}/latest', function (int $handle) {
return response()->json(Cache::get("plc:latest:{$handle}", null));
});
No round-trip to OPC UA — the cache is always within
publishingInterval ms of fresh.
Threshold-based alerting
use PhpOpcua\Client\Event\DataChangeReceived;
class AlertOnHighTemperature
{
private const TEMP_HANDLE = 42; // your assigned handle for the temp tag
public function handle(DataChangeReceived $event): void
{
if ($event->clientHandle !== self::TEMP_HANDLE) {
return;
}
$temp = (float) $event->dataValue->getValue();
if ($temp < 90.0) {
return;
}
// Throttle to one alert per 5 minutes per handle
$key = "alert-fired:{$event->clientHandle}";
if (Cache::has($key)) {
return;
}
Cache::put($key, true, minutes: 5);
Notification::route('slack', config('alerts.ops_channel'))
->notify(new HighTemperatureAlert($temp));
}
}
The throttling cache is essential — without it, a fluctuating sensor at the threshold can fire 100 alerts in a second.
Subscription keep-alives and lifecycle
There is no dedicated "subscription expired" event in opcua-client.
The events you can observe on a subscription's lifecycle are:
| Event | Fires when… |
|---|---|
SubscriptionCreated |
createSubscription() succeeded |
SubscriptionKeepAlive |
The server sent an empty publish response |
SubscriptionDeleted |
deleteSubscription() succeeded |
SubscriptionTransferred |
transferSubscriptions() returned Good |
If you stop receiving DataChangeReceived for a long stretch (e.g.
2× the keep-alive interval), the server most likely expired the
subscription due to a missed publish acknowledgement — re-create it.
Pair PublishResponseReceived with a "last seen" timestamp per
subscription to detect this from your application code; the library
does not raise an event on expiry itself.
Server status
There is no dedicated "server status" event class in opcua-client.
To track the server's running state, poll the
Server.ServerStatus.State node (i=2259) on a normal subscription
and react to its DataChangeReceived:
// Server.ServerStatus.State is a 0-based ServerState enum:
// 0=Running, 1=Failed, 2=NoConfiguration, 3=Suspended,
// 4=Shutdown, 5=Test, 6=CommunicationFault, 7=Unknown
Volume tuning
A tracker for in-flight event throughput:
use PhpOpcua\Client\Event\DataChangeReceived;
class TrackThroughput
{
public function handle(DataChangeReceived $event): void
{
Cache::increment('opcua:rate:' . now()->format('Y-m-d-H-i'));
}
}
Useful to graph events-per-minute and spot drops or spikes.
What NOT to do in a listener
- Synchronous HTTP calls. Queue them.
- Synchronous DB transactions across many tables. Queue them.
- Blocking I/O of any kind. Queue it.
- Heavy computation. Queue it.
- In-memory transforms. Fine synchronously.
- Single-row inserts. Fine only if the volume is low.
The rule: if the listener can take more than 5 ms in the 99th percentile, queue it.
Where to read next
- Alarm events — the alarm-specific notification.
- Queued listeners — scaling rules.
- Recipes · Persistent tag history — the end-to-end persistence pattern.