symfony-opcua · master
Docs · Events

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:

  1. opcua-client dispatches a typed event object (e.g. PhpOpcua\Client\Event\DataChangeReceived) on the PSR-14 dispatcher it was given.
  2. That dispatcher is Symfony's EventDispatcher.
  3. Listeners registered with #[AsEventListener] (or the EventSubscriberInterface) 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:

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

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

text services.yaml
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:

php filter by connection
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:

php filter by handle
#[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:

php async listener
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. DataChangeReceived carries an $event->client reference (the live OpcUaClientInterface), 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:

php Mercure listener
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'),
            ]),
        ));
    }
}

See Integrations · Mercure.

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

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

Documentation