symfony-opcua · master
Docs · Events

Connection events

Connection lifecycle events — fired in both direct and managed mode. The real classes live in opcua-client, not under PhpOpcua\\SymfonyOpcua\\Event.

opcua-client dispatches six PSR-14 events around the connection lifecycle. They fire in both direct and managed mode. All classes live in PhpOpcua\Client\Event\ (singular).

Event class Fires when Fields (besides $client)
ClientConnecting connect() started endpointUrl
ClientConnected Session activation succeeded endpointUrl
ClientDisconnecting disconnect() started
ClientDisconnected Disconnect completed (clean or broken) — (only $client)
ClientReconnecting reconnect() started (recovery attempt) endpointUrl
ConnectionFailed connect() raised endpointUrl, exception (Throwable)

There is no separate Reconnected event in opcua-client. After ClientReconnecting fires, a successful recovery dispatches ClientConnected again — listen for that.

ClientConnected

Fires when session activation succeeds.

Field Type Meaning
$client OpcUaClientInterface The live client instance
$endpointUrl string opc.tcp://...

Listener example:

php src/EventListener/LogConnections.php
namespace App\EventListener;

use PhpOpcua\Client\Event\ClientConnected;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;

final class LogConnections
{
    public function __construct(
        #[Autowire(service: 'monolog.logger.opcua')]
        private LoggerInterface $logger,
    ) {}

    #[AsEventListener]
    public function __invoke(ClientConnected $event): void
    {
        $this->logger->info('OPC UA connected', [
            'endpoint' => $event->endpointUrl,
        ]);
    }
}

#[Autowire] is Symfony's way to inject a specific Monolog channel into a service.

ClientDisconnected

Fires on explicit disconnect(), disconnectAll(), or broken transport. The class carries only $client — there is no reason field. If you need richer detail, listen for ConnectionFailed (which carries the original exception) or pair this event with logger/metric output from your own code.

use PhpOpcua\Client\Event\ClientDisconnected;

#[AsEventListener]
public function __invoke(ClientDisconnected $event): void
{
    // Inspect $event->client to identify which connection.
}

ConnectionFailed

Fires when connect() raised an exception.

Field Type Meaning
$client OpcUaClientInterface The live client instance
$endpointUrl string URL we tried
$exception \Throwable The exception that caused the failure

Common $exception types (from PhpOpcua\Client\Exception\):

Class Typical cause
ConnectionException TCP failure, server unreachable
HandshakeException Hello/Open SecureChannel rejected
SecurityException Policy/mode mismatch or trust failure
ProtocolException Server returned a non-conforming response
ServiceException Server returned a Bad_* service fault

Use the exception type for routing / alerting:

php src/EventListener/AlertOpsTeam.php
namespace App\EventListener;

use App\Notification\PlcConnectionLostNotification;
use PhpOpcua\Client\Event\ConnectionFailed;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\Notifier\NotifierInterface;
use Symfony\Component\Notifier\Recipient\Recipient;

final class AlertOpsTeam
{
    public function __construct(private NotifierInterface $notifier) {}

    #[AsEventListener]
    public function __invoke(ConnectionFailed $event): void
    {
        $notif = new PlcConnectionLostNotification(
            endpoint:  $event->endpointUrl,
            reason:    $event->exception::class,
            message:   $event->exception->getMessage(),
        );

        $this->notifier->send(
            $notif,
            new Recipient('[email protected]', '+15551234567'),
        );
    }
}

See Integrations · Notifier.

ClientReconnecting

Fires when an automatic or explicit reconnect() starts. There is no follow-up "Reconnected" event — a successful recovery dispatches ClientConnected again.

use PhpOpcua\Client\Event\ClientReconnecting;

#[AsEventListener]
public function __invoke(ClientReconnecting $event): void
{
    // log "attempting reconnect to {$event->endpointUrl}"
}

If you want to know when recovery succeeded (and how long the outage was), keep a (endpointUrl => lostAt) map on a service and update it from ClientDisconnected / ConnectionFailed; then in ClientConnected compute the down-time.

Patterns

Audit log

A single subscriber for the whole lifecycle:

php src/EventListener/WriteConnectionAudit.php
namespace App\EventListener;

use App\Entity\ConnectionAudit;
use Doctrine\ORM\EntityManagerInterface;
use PhpOpcua\Client\Event\{
    ClientConnected,
    ClientDisconnected,
    ClientReconnecting,
    ConnectionFailed,
};
use Symfony\Component\EventDispatcher\EventSubscriberInterface;

final class WriteConnectionAudit implements EventSubscriberInterface
{
    public function __construct(private EntityManagerInterface $em) {}

    public static function getSubscribedEvents(): array
    {
        return [
            ClientConnected::class    => 'logConnected',
            ClientDisconnected::class => 'logDisconnected',
            ClientReconnecting::class => 'logReconnecting',
            ConnectionFailed::class   => 'logFailed',
        ];
    }

    public function logConnected(ClientConnected $e): void
    {
        $this->store($e->endpointUrl, 'connected', null);
    }
    public function logDisconnected(ClientDisconnected $e): void
    {
        $this->store($this->endpointOf($e->client), 'disconnected', null);
    }
    public function logReconnecting(ClientReconnecting $e): void
    {
        $this->store($e->endpointUrl, 'reconnecting', null);
    }
    public function logFailed(ConnectionFailed $e): void
    {
        $this->store($e->endpointUrl, 'failed', $e->exception->getMessage());
    }

    private function store(string $endpoint, string $state, ?string $detail): void
    {
        $entry = (new ConnectionAudit())
            ->setEndpoint($endpoint)
            ->setState($state)
            ->setDetail($detail)
            ->setLoggedAt(new \DateTimeImmutable());

        $this->em->persist($entry);
        $this->em->flush();
    }

    private function endpointOf(object $client): string
    {
        return method_exists($client, 'getEndpointUrl')
            ? (string) $client->getEndpointUrl()
            : 'unknown';
    }
}

Dashboard tile via Symfony cache

php src/EventListener/TrackConnectionState.php
namespace App\EventListener;

use PhpOpcua\Client\Event\{
    ClientConnected,
    ClientDisconnected,
    ConnectionFailed,
};
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;

final class TrackConnectionState implements EventSubscriberInterface
{
    public function __construct(private CacheInterface $cache) {}

    public static function getSubscribedEvents(): array
    {
        return [
            ClientConnected::class    => 'up',
            ClientDisconnected::class => 'down',
            ConnectionFailed::class   => 'failed',
        ];
    }

    public function up(ClientConnected $event): void
    {
        $this->state($event->endpointUrl, 'up');
    }
    public function down(ClientDisconnected $event): void
    {
        $this->state($this->endpointOf($event->client), 'down');
    }
    public function failed(ConnectionFailed $event): void
    {
        $this->state($event->endpointUrl, 'failed');
    }

    private function state(string $endpoint, string $value): void
    {
        $key = 'opcua.state.' . sha1($endpoint);
        $this->cache->delete($key);
        $this->cache->get($key, function (ItemInterface $i) use ($value) {
            $i->expiresAfter(3600);
            return ['state' => $value, 'at' => (new \DateTimeImmutable())->format('c')];
        });
    }

    private function endpointOf(object $client): string
    {
        return method_exists($client, 'getEndpointUrl')
            ? (string) $client->getEndpointUrl()
            : 'unknown';
    }
}

A /plant/status controller reads from cache for a real-time tile.

Flap detection

php src/EventListener/DetectFlapping.php
namespace App\EventListener;

use PhpOpcua\Client\Event\ClientReconnecting;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;

final class DetectFlapping
{
    public function __construct(
        private CacheInterface $cache,
        #[Autowire(service: 'monolog.logger.opcua')]
        private LoggerInterface $logger,
    ) {}

    #[AsEventListener]
    public function __invoke(ClientReconnecting $event): void
    {
        $key = 'opcua.flap.' . sha1($event->endpointUrl);
        $count = $this->cache->get($key, function (ItemInterface $i) {
            $i->expiresAfter(600);    // 10 minutes
            return 0;
        });
        $this->cache->delete($key);
        $this->cache->get($key, function (ItemInterface $i) use ($count) {
            $i->expiresAfter(600);
            return $count + 1;
        });

        if ($count + 1 >= 5) {
            $this->logger->warning('OPC UA flapping', [
                'endpoint' => $event->endpointUrl,
            ]);
            $this->cache->delete($key);
        }
    }
}

Performance notes

Connection events fire once per lifecycle event — inherently low-volume. Queue them only if listeners do expensive work (Slack notifications, external HTTP).

A simple log-channel listener can stay synchronous — microseconds.