Events overview
How the bundle exposes OPC UA events to Symfony. Symfony's EventDispatcher implements PSR-14, so opcua-client's real events flow through #[AsEventListener] natively — no bridge class required.
This bundle does not ship its own event classes. Instead it relies on
the events dispatched by the underlying opcua-client library
(catalogued in
opcua-client · Event reference)
and on Symfony's own EventDispatcher.
Why this just works
PhpOpcuaSymfonyOpcuaBundle wires
Psr\EventDispatcher\EventDispatcherInterface into OpcuaManager
during the container build (loadExtension() injects the service via
new Reference(EventDispatcherInterface::class, NULL_ON_INVALID_REFERENCE)).
In Symfony, that PSR-14 interface is implemented by
Symfony\Component\EventDispatcher\EventDispatcher (since 4.3).
The chain is:
opcua-clientdispatches a typed event object (e.g.PhpOpcua\Client\Event\DataChangeReceived) on the PSR-14 dispatcher it was given.- That dispatcher is Symfony's
EventDispatcher. - Listeners registered with
#[AsEventListener](or theEventSubscriberInterface) for the event's class name receive it.
In managed mode (opcua-session-manager) the same thing happens
inside the daemon: AutoPublisher dispatches the same
PhpOpcua\Client\Event\* classes on the PSR-14 dispatcher
SessionCommand wires up — Symfony's EventDispatcher.
There is no OpcuaEventBridge class, and you don't need one.
The event classes
All event classes live under PhpOpcua\Client\Event\ (singular) —
not under PhpOpcua\Client\Events\ and not under
PhpOpcua\SymfonyOpcua\Event\. The full catalogue (47 classes) is
in the
opcua-client event reference.
The most useful slices for Symfony apps:
| Group | Class | Fields (besides $client) |
|---|---|---|
| Connection lifecycle | ClientConnecting |
endpointUrl |
ClientConnected |
endpointUrl |
|
ClientDisconnecting |
— | |
ClientDisconnected |
— (no reason) |
|
ClientReconnecting |
endpointUrl (signals an attempt — there is no separate Reconnected) |
|
ConnectionFailed |
endpointUrl, exception |
|
| Subscriptions | SubscriptionCreated, SubscriptionDeleted, SubscriptionKeepAlive, SubscriptionTransferred |
subscriptionId (+ extras) |
| Monitored items | MonitoredItemCreated, MonitoredItemModified, MonitoredItemDeleted |
subscriptionId, monitoredItemId, … |
| Publish | DataChangeReceived |
subscriptionId, sequenceNumber, clientHandle, dataValue |
EventNotificationReceived |
subscriptionId, sequenceNumber, clientHandle, eventFields |
|
PublishResponseReceived |
subscriptionId, sequenceNumber, notificationCount, moreNotifications |
|
| Alarms | AlarmActivated, LimitAlarmExceeded, AlarmAcknowledged, … |
see alarm events page |
Publish-time events (
DataChangeReceived,EventNotificationReceived, alarm events) only fire when something is driving the publish loop. In managed mode with auto-publish the daemon drives it for you. In direct mode they only fire when your code calls$client->publish(...)on a subscription.
Listening — #[AsEventListener]
The modern way:
namespace App\EventListener;
use PhpOpcua\Client\Event\DataChangeReceived;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
final class StoreSpeedReading
{
#[AsEventListener]
public function __invoke(DataChangeReceived $event): void
{
$value = $event->dataValue->getValue();
// $event->clientHandle identifies which monitored item produced this
// ...
}
}
Symfony's autoconfiguration finds the attribute. No
services.yaml entry needed.
Listening — EventSubscriberInterface style
For listeners that handle multiple events:
namespace App\EventListener;
use PhpOpcua\Client\Event\{ClientConnected, ClientDisconnected};
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
final class ConnectionAuditSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [
ClientConnected::class => 'onConnected',
ClientDisconnected::class => 'onDisconnected',
];
}
public function onConnected(ClientConnected $event): void { /* ... */ }
public function onDisconnected(ClientDisconnected $event): void { /* ... */ }
}
The subscriber is autoconfigured via the EventSubscriberInterface
interface — no services.yaml work.
Old-style YAML registration
For backwards compatibility or non-standard cases:
services:
App\EventListener\StoreSpeedReading:
tags:
- { name: kernel.event_listener, event: 'PhpOpcua\Client\Event\DataChangeReceived', method: '__invoke' }
Prefer the attribute — it lives next to the class. Note the
namespace is Event\ (singular).
Per-connection filtering
The events do not carry a Symfony "connection name" — they carry the
live $client instance. If you need to know which named connection
produced the event, compare instances:
use PhpOpcua\Client\Event\DataChangeReceived;
use PhpOpcua\SymfonyOpcua\OpcuaManager;
final class StoreSpeedReading
{
public function __construct(private OpcuaManager $opcua) {}
#[AsEventListener]
public function __invoke(DataChangeReceived $event): void
{
if ($event->client !== $this->opcua->connection('plc-line-a')) {
return; // ignore other lines
}
// ...
}
}
For most apps it is simpler to register a different listener per connection by binding the event manually on the dispatcher attached to that specific client.
Per-node filtering
Monitored-item events expose $clientHandle, the value you assigned
when you called createMonitoredItems() / createEventMonitoredItem().
Keep a clientHandle => nodeId map on your service and look up the
node ID in the listener:
#[AsEventListener]
public function __invoke(DataChangeReceived $event): void
{
if ($event->clientHandle !== 1) { // 1 = ns=2;s=Speed
return;
}
// ...
}
DataChangeReceived does not carry the nodeId directly — only
the clientHandle you assigned at item-creation time.
| Listener role | Filter |
|---|---|
| Persistence — every value | No filter |
| UI broadcast — high-freq tags | $event->clientHandle in a whitelist |
| Alerts — thresholds | $event->clientHandle + value comparison |
Async listeners
For listeners that do non-trivial work, route through Messenger:
namespace App\EventListener;
use App\Message\StoreReadingMessage;
use PhpOpcua\Client\Event\DataChangeReceived;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\Messenger\MessageBusInterface;
final class DispatchDataChange
{
public function __construct(private MessageBusInterface $bus) {}
#[AsEventListener]
public function __invoke(DataChangeReceived $event): void
{
$this->bus->dispatch(new StoreReadingMessage(
clientHandle: $event->clientHandle,
value: $event->dataValue->getValue(),
statusCode: $event->dataValue->statusCode,
at: $event->dataValue->sourceTimestamp,
));
}
}
The listener returns immediately; the work runs on a worker. See Async listeners with Messenger.
A note on serialisation.
DataChangeReceivedcarries an$event->clientreference (the liveOpcUaClientInterface), which is not safely serialisable for queued listeners. When dispatching a Messenger envelope, extract the primitive fields you need (clientHandle, dataValue primitive, subscriptionId) before dispatching.
Mercure broadcasting
Push events to the browser:
namespace App\EventListener;
use PhpOpcua\Client\Event\DataChangeReceived;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
final class BroadcastDataChange
{
public function __construct(private HubInterface $hub) {}
#[AsEventListener]
public function __invoke(DataChangeReceived $event): void
{
$this->hub->publish(new Update(
topics: ['/plc/handle/' . $event->clientHandle],
data: json_encode([
'value' => $event->dataValue->getValue(),
'at' => $event->dataValue->sourceTimestamp?->format('c'),
]),
));
}
}
When events fire — the timing
| Event | Fires when… |
|---|---|
ClientConnected |
Session activation succeeds |
ClientDisconnected |
Disconnect (clean or broken) finished |
ConnectionFailed |
connect() raised |
ClientReconnecting |
reconnect() started (no separate "Reconnected" event — ClientConnected fires again on success) |
DataChangeReceived |
A publish response carried a data-change notification |
EventNotificationReceived |
A publish response carried an event notification |
PublishResponseReceived |
Any publish response (including keep-alives) |
SubscriptionKeepAlive |
Server sent an empty publish response |
All events are synchronous on the dispatch path. Long listeners block the event-emitting code. Use Messenger for anything heavier than a few milliseconds.
Debug listener registration
php bin/console debug:event-dispatcher "PhpOpcua\Client\Event\DataChangeReceived"
Shows all listeners + priorities for that event class. Note the
namespace is Event\ (singular) — the plural form was never the
real class name.
Where to read next
- Connection events — lifecycle events.
- Data events — subscription value changes.
- Alarm events — alarm pipeline.
- Async listeners with Messenger — scaling the listener side.