Events
PSR-14 events are how you wire metrics, alerts, and reactions to the OPC UA lifecycle. Wire a dispatcher once, listen to the events you care about, ignore the rest.
The library dispatches 47 event classes through PSR-14. They cover the full lifecycle: connection, session, secure channel, subscription, monitored items, read / write / browse, alarms, retries, cache, trust store. Wire a dispatcher and listen — there is no other configuration.
The full event catalogue lives in Event reference. This page is about using events.
Wiring a dispatcher
Any PSR-14 EventDispatcherInterface works:
use PhpOpcua\Client\ClientBuilder;
use PhpOpcua\Client\Event\NodeValueWritten;
use Symfony\Component\EventDispatcher\EventDispatcher;
$dispatcher = new EventDispatcher();
$dispatcher->addListener(NodeValueWritten::class, function (NodeValueWritten $e) {
metric('opcua.writes', 1, ['node' => (string) $e->nodeId]);
});
$client = ClientBuilder::create()
->setEventDispatcher($dispatcher)
->connect('opc.tcp://plc.local:4840');
Without setEventDispatcher(), the client uses NullEventDispatcher
— a no-op. Cost: zero.
Listening idioms
PSR-14 dispatchers are dumb pipes. Two listener idioms cover most cases:
Per-event class — typed, simple.
$dispatcher->addListener(ClientReconnecting::class, function (ClientReconnecting $e) {
$this->logger->warning('opcua.reconnecting', ['endpoint' => $e->endpoint]);
});
Class hierarchy — broader nets.
Some dispatcher implementations support listener registration against
a base class. The library's alarm events all extend a common
AlarmEventReceived-shaped surface, so a listener on the parent
catches every alarm variant. Check your dispatcher's docs — Symfony's
EventDispatcher does not do this; league/event does.
Where events fire
Each event has a single dispatch point. The contract:
- Lifecycle events fire after the underlying transition is
visible.
ClientConnectedfires after the session is activated and beforeconnect()returns.ClientDisconnectedfires after the socket is closed. - Service-call events fire after the response is decoded.
NodeValueReadfires afterread()has itsDataValue; the event carries the value. - Error events fire before the exception is rethrown.
ConnectionFailedandNodeValueWriteFailedgive listeners a chance to record the failure before the call site sees it. - Cache / retry events fire inline, around the hot path.
Listeners should be fast — a slow
CacheHitlistener will dominate the cost of a cache hit.
The dispatcher contract
The library expects a PSR-14 dispatcher that runs listeners synchronously, in the calling thread, before returning. The library makes no thread-safety claims about asynchronous dispatchers; if your dispatcher defers, the event-payload references may be stale by the time the listener runs.
A worked example — track every read
use PhpOpcua\Client\Event\NodeValueRead;
$dispatcher->addListener(NodeValueRead::class, function (NodeValueRead $e) use ($metrics) {
$metrics->counter('opcua.reads', tags: [
'endpoint' => $e->endpoint,
'good' => StatusCode::isGood($e->dataValue->statusCode),
])->increment();
if (! StatusCode::isGood($e->dataValue->statusCode)) {
$metrics->counter('opcua.reads.bad_status', tags: [
'statusCode' => StatusCode::getName($e->dataValue->statusCode),
])->increment();
}
});
This is the canonical instrumentation pattern. Wire it once, get
visibility everywhere read() is called — including in third-party
modules that compose read() internally.
A worked example — alarm dispatch
Alarms are events with extra fields. The library auto-deduces which alarm-typed event to dispatch and gives you a typed listener target per transition:
use PhpOpcua\Client\Event\AlarmActivated;
use PhpOpcua\Client\Event\AlarmAcknowledged;
use PhpOpcua\Client\Event\LimitAlarmExceeded;
$dispatcher->addListener(AlarmActivated::class, fn($e) =>
pagerduty()->trigger($e->sourceName, $e->message, $e->severity)
);
$dispatcher->addListener(AlarmAcknowledged::class, fn($e) =>
pagerduty()->resolve($e->sourceName)
);
$dispatcher->addListener(LimitAlarmExceeded::class, fn($e) =>
audit()->record('limit-exceeded', $e->sourceName, $e->limitState)
);
The generic AlarmEventReceived fires for every alarm — useful for
"log every alarm" surfaces. The specific variants
(AlarmActivated, AlarmDeactivated, etc.) fire when the relevant
state field crosses a transition. Pick the level of granularity that
fits your reaction.
See Event reference for the full alarm matrix.
Throwing from a listener
Listener exceptions propagate. If a listener throws, the dispatcher typically propagates the exception, which interrupts the OPC UA call path. The library treats listener exceptions like any other unchecked exception — they bubble to the caller as-is. To insulate the OPC UA call from listener failures, wrap your listener body in a try/catch.
This is a deliberate choice: the library does not silently swallow listener exceptions, because a silent swallow would hide bugs in instrumentation.
When events are the wrong tool
- You want a full audit log. Use the PSR-3 logger. Events are for reactions, not for sequential record-keeping.
- You want to mutate behaviour. Events are read-only signals. To change what the client does, swap the module — see Extensibility · Replacing modules.
- You need cross-process delivery. PSR-14 is in-process. Republish to a queue from a listener if you need cross-process reach.
Performance
Listener cost is on the hot path of every dispatched event. A slow
listener — anything that blocks on I/O, anything that does meaningful
work — multiplies the call cost. The library dispatches an event per
service call at minimum; a busy worker dispatching to a 50 ms HTTP
sink per read() will be CPU-bound on dispatch, not on OPC UA.
When in doubt, buffer in the listener (in-memory ring buffer, batched flush) and ship asynchronously.