Blog 9 min read
php-opcua v4.6.0: Self-Healing Sessions Survive Restarts
php-opcua v4.6.0 makes sessions self-healing across reconnects and process restarts, adds data-change filters, and richer subscription diagnostics.
Gianfrancesco Aurecchia
@GianfriAur
Long-running connections are where an OPC UA client earns its keep — a network blip that shouldn't cost you your subscriptions, a session that outlives the worker process that created it, a secure channel whose token expires mid-shift. v4.6.0 makes the client self-healing on every one of those fronts: sessions now patch themselves back together after a reconnect and after a full process restart, and the client gives subscriptions finer-grained control and richer diagnostics on top.
TL;DR
Sessions are self-healing on three fronts: a dropped connection reactivates the same session on a new secure channel instead of replacing it, so subscriptions and monitored items keep running; the session can be suspended and resumed by a different process after a restart; and an idle session past its timeout is recreated fresh. Secure channel tokens renew themselves on schedule too — all of it on by default. Monitored items gain data-change (deadband) filters and a discardOldest flag at creation time. publish() and republish() share one decoder and report richer results: publish time, per-acknowledgement status, and a republished flag your listeners can check. StatusCode::getName() now names all 272 standard codes.
Sessions that survive a reconnect — and a restart
This is where the self-healing story goes furthest. On a dropped connection, reconnect() — and so setAutoRetry(), which calls it — now opens a new secure channel and reactivates the existing session on it with ActivateSession: same authentication token, same server nonce. Subscriptions and monitored items keep running, and SessionReactivated is dispatched. If the server no longer recognizes the session, a fresh one is created automatically, same as before.
use PhpOpcua\Client\ClientBuilder;
use PhpOpcua\Client\Event\SessionReactivated;
$dispatcher->addListener(SessionReactivated::class, function (SessionReactivated $e) use ($logger) {
$logger->info('opcua.session_reactivated', ['endpoint' => $e->endpointUrl]);
});
$client = ClientBuilder::create()
->setEventDispatcher($dispatcher)
->setAutoRetry(3) // triggers reconnect() on ConnectionException
->setReactivateSession() // on by default
->connect('opc.tcp://plc.local:4840');
// A network blip during the publish loop: reconnect() reuses this session,
// the subscription keeps delivering data changes afterward.
setReactivateSession(false) restores the old behavior of a fresh session on every reconnect. This is a different path from setRecreateExpiredSession(): that one fires when the server has already declared the session invalid (idle past its timeout) and always gets a new session — there's no session left to reactivate. Reactivation is for the case where the connection itself dropped but the session, as far as the server's concerned, might still be alive.
Resuming a session in another process
A session outlives the process that created it, until its own timeout expires. suspend() closes the secure channel and the socket without closing the session, and returns the SessionState needed to reactivate it later — from a different process, after a graceful redeploy or a crash:
use PhpOpcua\Client\Types\SessionState;
// before exiting (or periodically, to survive a crash)
file_put_contents($path, json_encode($client->suspend()));
// after the restart, in a new process
$state = SessionState::fromArray(json_decode(file_get_contents($path), true));
$client = ClientBuilder::create()
->resumeSession($state)
->connect($state->endpointUrl);
getSessionState() returns the same state without disconnecting, if you'd rather checkpoint periodically than suspend outright. resumeSession() reactivates it on connect(); if the server has moved on, a new session is created instead, same fallback as a plain reconnect.
The state is a credential
The session state holds the authentication token and server nonce that let a process step into a live session — store it with the same care as a session cookie or an API token, not as a plain log line.

Whichever path brings the session back, notifications the server sent while you were gone aren't lost: unacknowledged sequence numbers show up in availableSequenceNumbers on the next PublishResult, and republish() — covered below — fetches them.
Connections that heal themselves
A session that has been idle for a while, or a secure channel whose security token has run past its lifetime, used to mean writing your own retry-and-reconnect glue around the client. v4.6.0 moves that into the client itself.
executeWithRetry() now recognizes BadSessionIdInvalid, BadSessionClosed and BadSessionNotActivated on any call, recreates the session, and repeats the call once — independently of setAutoRetry(), which still governs connection-level retries. The secure channel's security token renews on its own schedule too: the client tracks the lifetime the server actually granted and sends an OpenSecureChannel renewal at 75% of it, with a fresh nonce and new symmetric keys when security is active, so a connection that's been open for hours keeps using the same session without you polling for it.
use PhpOpcua\Client\ClientBuilder;
use PhpOpcua\Client\Event\SecureChannelRenewed;
$dispatcher = new \Symfony\Component\EventDispatcher\EventDispatcher();
$dispatcher->addListener(SecureChannelRenewed::class, function (SecureChannelRenewed $e) use ($logger) {
$logger->info('opcua.channel_renewed', [
'channelId' => $e->channelId,
'tokenId' => $e->tokenId,
'revisedLifetime' => $e->revisedLifetime,
]);
});
$client = ClientBuilder::create()
->setEventDispatcher($dispatcher)
->setSessionTimeout(60_000.0) // ask for 60s, the server may revise it
->setRecreateExpiredSession() // on by default
->setRenewSecurityToken() // on by default
->setAutoRetry(3)
->connect('opc.tcp://plc.local:4840');
// The timeout the server actually granted, not the one you asked for.
$granted = $client->getSessionTimeout();
Both behaviors are on by default and can be turned off individually — ClientBuilder::setRecreateExpiredSession(false) and setRenewSecurityToken(false) — if you'd rather manage reconnection yourself. getSessionTimeout() (on both Client and ClientBuilder) always reflects the server's revised value, useful for sizing your own idle/keepalive logic.

Bigger responses in one call
The transport also assembles responses that a server splits across multiple message chunks, so a large historyReadRaw() or readFile() call completes as a single call instead of needing to be paged manually on your side.
Finer control over monitored items
createMonitoredItems() and modifyMonitoredItems() now accept a filter key, encoded as a DataChangeFilter, so the server — not your listener — decides which changes are worth reporting:
$sub = $client->createSubscription(publishingInterval: 500.0);
$client->createMonitoredItems($sub->subscriptionId)
->add('ns=2;s=Devices/PLC/Temperature')
->samplingInterval(500.0)
->dataChangeFilter(trigger: 1, deadbandType: 1, deadbandValue: 0.5) // Absolute deadband of 0.5
->add('ns=2;s=Devices/PLC/AlarmQueue')
->samplingInterval(200.0)
->queueSize(10)
->discardOldest(false) // keep the oldest queued values instead of dropping them
->execute();
trigger picks Status (0), StatusValue (1, the default) or StatusValueTimestamp (2); deadbandType is None (0), Absolute (1) or Percent (2, which needs an EURange on the variable — the server rejects the item with BadMonitoredItemFilterUnsupported when it's missing). discardOldest, previously settable only through modifyMonitoredItems(), can now be set at creation time too, and MonitoredItemsBuilder exposes monitoringMode(), discardOldest() and dataChangeFilter() so the fluent API covers every item key the raw array form does.

Subscriptions report more of what happened
publish() and republish() now share a single notification decoder, so republish() returns the same typed DataChangeNotification and EventNotification objects publish() does — instead of an empty array — and dispatches the matching PSR-14 events (DataChangeReceived, EventNotificationReceived, and the alarm events). Every one of those events now carries republished: bool, so a listener that must not process a value twice can tell a live delivery from a retransmission:
use PhpOpcua\Client\Event\DataChangeReceived;
$dispatcher->addListener(DataChangeReceived::class, function (DataChangeReceived $e) {
if ($e->republished) {
return; // already processed this sequence number the first time around
}
// handle the fresh value
});
// Ask the server to resend notifications your acknowledgements never reached.
$result = $client->republish($sub->subscriptionId, $missedSequenceNumber);
foreach ($result['notifications'] as $notification) {
// same DataChangeNotification / EventNotification objects publish() returns
}

PublishResult gains $publishTime (when the server sent the notification message) and $acknowledgementResults (the status of each acknowledgement you sent with the request, in order — Good, or BadSequenceNumberUnknown / BadSubscriptionIdInvalid when one didn't land):
$publish = $client->publish(acknowledgements: $pendingAcks);
echo $publish->publishTime?->format('c');
foreach ($publish->acknowledgementResults as $status) {
// one entry per acknowledgement you sent, in order
}
And when an event filter's select clause gets rejected — an unknown property name, say — MonitoredItemResult::$selectClauseResults now tells you which one, instead of leaving you to guess from a single item-level status:
[$result] = $client->createMonitoredItems($sub->subscriptionId, [
['nodeId' => $serverNode, 'filter' => $eventFilter],
]);
foreach ($result->selectClauseResults as $index => $status) {
if ($status !== StatusCode::Good) {
// the select clause at $index was rejected
}
}
Errors you can actually read
StatusCode::getName() used to know about a handful of hand-picked constants and fell back to a bare hex string for everything else. It now names all 272 standard status codes, generated straight from the OPC Foundation's UA-Nodeset StatusCode.csv, and appends the set InfoBits in brackets:
use PhpOpcua\Client\Types\StatusCode;
echo StatusCode::getName($dataValue->getStatusCode());
// "Good [LimitHigh, Overflow]" — instead of a raw 0x... code
browse(), browseWithContinuation(), browseNext(), browseAll() and browseRecursive(), along with historyReadRaw(), historyReadProcessed() and historyReadAtTime(), now raise a ServiceException carrying the server's status code whenever the underlying result is Bad, so a node that doesn't exist or a history read the server rejects is no longer indistinguishable from "no data":
use PhpOpcua\Client\Exception\ServiceException;
try {
$children = $client->browse($nodeId);
} catch (ServiceException $e) {
// e.g. BadNodeIdUnknown — the node genuinely doesn't exist
}
Updating from an earlier version
If your code relied on browse() or historyReadRaw() / historyReadProcessed() / historyReadAtTime() returning an empty array for a node the server rejects, wrap those calls in a try/catch (ServiceException) after upgrading — a Bad result now throws instead of returning []. Everything else in this release is additive and on by default; no other call changes shape.
Try it
composer require php-opcua/opcua-client:^4.6
Every behavior above is covered by integration tests, most of them run against UA-.NETStandard, the OPC Foundation's reference implementation, and open62541. See the full changelog for the complete list, and the subscriptions documentation for the full monitored-items and events reference.
Keep reading
OPC UA sessions vs HTTP API calls, explained for PHP devs
OPC UA sessions vs HTTP API calls, explained for PHP devs
OPC UA in Pure PHP: Introducing the php-opcua Project
php-opcua brings the OPC UA binary protocol to pure PHP: client, CLI and Laravel integration, no C extensions. Read your first PLC value in minutes.
OPC UA Security in PHP: Policies, Certificates, and Trust
Ten security policies, three trust modes, and the certificate flow between php-opcua and your PLC — from wide-open defaults to production-ready.