Opening and closing
The connection lifecycle has three states and three transitions. Get the state machine right and most "the client just hung" bugs disappear.
The client's connection state is exposed as a three-case enum,
ConnectionState:
┌──────────────────────┐
│ Disconnected │ ← initial state
└──────────────────────┘
│ ▲
connect()│ │ disconnect()
▼ │
┌──────────────────────┐
│ Connected │
└──────────────────────┘
│ ▲
I/O error, │ │ reconnect()
server reset, │ │ succeeds
stale token │ │
▼ │
┌──────────────────────┐
│ Broken │
└──────────────────────┘
Three states, three transitions:
| From | Transition | To |
|---|---|---|
Disconnected |
connect() |
Connected |
Connected |
I/O error | Broken |
Broken |
reconnect() |
Connected |
| any | disconnect() |
Disconnected |
Read the state with $client->getConnectionState(). The boolean
shortcut $client->isConnected() returns true only when the state is
Connected.
Opening
There is no separate open() call. ClientBuilder::connect() returns
a Client already in ConnectionState::Connected. Under the hood it
performs:
- TCP open + HEL/ACK handshake
- GetEndpoints discovery, if needed
OpenSecureChannel(OPN)CreateSession+ActivateSession
If any step fails, no Client is returned — connect() throws and the
caller never sees a half-initialised object.
use PhpOpcua\Client\ClientBuilder;
use PhpOpcua\Client\Exception\ConnectionException;
use PhpOpcua\Client\Exception\SecurityException;
try {
$client = ClientBuilder::create()
->setTimeout(10.0)
->connect('opc.tcp://plc.local:4840');
} catch (ConnectionException $e) {
// TCP failure, DNS error, HEL/ACK timeout, server refused, etc.
throw $e;
} catch (SecurityException $e) {
// Certificate validation, key load, OpenSSL primitive failure
throw $e;
}
Configure the builder before calling connect() — the builder is
the only configuration surface. Once you have a Client, the connection
parameters (security, timeout, batching, …) are frozen until you build
a new one.
Closing
Always disconnect() when you are done. The call sends CloseSession
and CloseSecureChannel to the server and frees the TCP socket. Both
messages can fail silently — disconnect() does not raise on the
return trip, so it is safe in finally blocks.
$client = ClientBuilder::create()->connect('opc.tcp://plc.local:4840');
try {
$client->read('i=2261');
// … real work …
} finally {
$client->disconnect();
}
Warning
A Client that has been disconnect()ed cannot be reused for normal
operations — every service call will raise ConnectionException("Not connected: call connect() first"). To revive it, call reconnect().
The builder is a one-shot factory by design; if you need a second
connection, build a second client.
Reconnecting
When a connection drops, the client transitions to
ConnectionState::Broken on the next service call. Call reconnect()
to rebuild the channel and session against the same endpoint, with the
same configuration:
reconnect() opens a new secure channel and reactivates the existing
session on it, so its subscriptions and monitored items keep running
and SessionReactivated is dispatched. Notifications the server sent
but you did not acknowledge are listed in availableSequenceNumbers on
the next PublishResult; fetch them with republish(). If the server
no longer knows the session, a new one is created and the
subscriptions must be recreated — see Recipes · Recovering from
disconnection.
setReactivateSession(false) on the builder always creates a new
session.
Resuming a session in another process
A session outlives the process that created it until its session timeout expires. Save it and reactivate it after a restart:
use PhpOpcua\Client\Types\SessionState;
// before exiting (or periodically, to survive a crash)
file_put_contents($path, json_encode($client->suspend()));
// after the restart
$state = SessionState::fromArray(json_decode(file_get_contents($path), true));
$client = ClientBuilder::create()
->resumeSession($state)
->connect($state->endpointUrl);
suspend() closes the secure channel and the socket without closing
the session; getSessionState() returns the same state without
disconnecting. The state holds the session's authentication token and
server nonce: store it with restrictive permissions. When the server
rejects it, connect() creates a new session. Against UA-.NETStandard,
an anonymous session resumed 15 s after the process crashed delivers
every value its subscription sampled in the meantime.
Detecting a broken connection
A connection only flips to Broken after the client tries to use it.
There is no background heartbeat in this library. The detection points
are:
- A blocking read or write on the socket fails or times out.
- The server returns a session-invalid status (
BadSessionIdInvalid,BadSessionNotActivated). - The secure channel is rejected (
BadSecureChannelClosed).
When that happens, the client dispatches ClientDisconnected, sets
the state to Broken, and raises the underlying exception to the
caller. The next call into the client will
raise ConnectionException("Connection broken: call reconnect()") —
the state remains stuck on Broken until reconnect() succeeds or
disconnect() is called.
Auto-retry
For transient failures inside a single call, the client supports an opt-in retry loop. Configure it on the builder:
$client = ClientBuilder::create()
->setAutoRetry(maxRetries: 3)
->connect('opc.tcp://plc.local:4840');
When maxRetries > 0, every service method routes through
executeWithRetry. On a recoverable error it calls reconnect() and
re-issues the request, up to maxRetries total tries. See Connection
· Timeouts and retry for the classification
of recoverable vs fatal failures.
Security token renewal
The server grants the secure channel a security token with a limited
lifetime. At 75% of that lifetime the client renews it on the first
request that follows, on the same channel and session, and dispatches
SecureChannelRenewed. A connection idle past the lifetime renews on its
next request. Renewal is on by default; setRenewSecurityToken(false) on
the builder turns it off, and the server then rejects requests once the
token expires.
Lifecycle events
The connection lifecycle is fully observable via PSR-14 events. Wire a
dispatcher with setEventDispatcher() and listen for:
| Event | When |
|---|---|
ClientConnecting |
connect() started |
ClientConnected |
Session activated; client is usable |
ConnectionFailed |
connect() raised — payload carries the exception |
ClientDisconnecting |
disconnect() started |
ClientDisconnected |
Disconnected (clean or broken — distinguish via prior ConnectionFailed / ClientDisconnecting) |
ClientReconnecting |
reconnect() started |
SecureChannelOpened / SecureChannelClosed |
OPN / CLO frames |
SecureChannelRenewed |
Security token renewed |
SessionCreated / SessionActivated / SessionClosed |
CreateSession / ActivateSession / CloseSession |