Custom transports
Swap the wire transport — TCP today, anything you want tomorrow. ClientTransportInterface is the contract; TcpTransport is the default; everything else is one PSR-4 class away.
Added in v4.4.0. The client moves OPC UA framed messages between PHP
and the server through a single abstraction — ClientTransportInterface.
The default implementation is TcpTransport, which speaks opc.tcp://
over a regular TCP socket. Implementing the interface lets you target
alternative encapsulations (opc.tls://, opc.https://, opc.wss://,
an in-process loopback) without touching the rest of the library.
This is the contract that any new transport implements:
namespace PhpOpcua\Client\Transport;
interface ClientTransportInterface
{
public function connect(string $host, int $port, null|float $timeout = null): void;
public function send(string $data): void;
public function receive(): string;
public function setReceiveBufferSize(int $size): void;
public function close(): void;
public function isConnected(): bool;
}
Six methods. The framing (the OPC UA 8-byte message header) is already
in $data for send(); receive() parses that same header to read
exactly one complete message back. The transport is byte-level — it
never inspects payload semantics.
When to write a custom transport
| Scenario | Custom transport pays off |
|---|---|
| Tunnel through HTTPS / WebSocket to traverse a proxy | Yes — implement opc.https:// or opc.wss:// |
Defense-in-depth TLS under opc.tcp:// |
Yes — wrap a TLS stream and implement against it |
| Unit tests without a real OPC UA server | Yes — see the InMemoryTransport example below |
| Replace TCP timeout / retry semantics | Often unnecessary — setAutoRetry() + setTimeout() cover most cases |
| Add PubSub support | No — use the sibling opcua-client-ext-pubsub (different request/response vs broadcast model) |
The interface is intentionally shaped for the request/response
pattern OPC UA uses over opc.tcp://. PubSub's broadcast model needs
a different surface; that's why it lives in its own package with
its own PubSubTransportInterface.
Wiring a custom transport
Pass an instance to ClientBuilder::setTransport() before connect():
use PhpOpcua\Client\ClientBuilder;
$client = ClientBuilder::create()
->setTransport(new MyHttpsTransport())
->connect('opc.https://plc.example.org:443/UA/MyServer');
Without setTransport(), the builder leaves the transport null and
the client falls back to new TcpTransport() — fully backward
compatible.
ClientBuilder::getTransport() returns the configured instance (or
null) for inspection.
The TcpTransport reference implementation
TcpTransport (src/Transport/TcpTransport.php) is ~150 lines and
the simplest example of a correct implementation:
connect()callsstream_socket_client('tcp://host:port', ...)send()loopsfwrite()until the whole payload is writtenreceive()reads the 8-byte header, decodes the message size from bytes 4-7, then reads the body via areadExact()helper that loopsfread()until the requested length is collectedsetReceiveBufferSize()caps the largest messagereceive()will accept — the client calls this after the HEL/ACK handshake with the value the server negotiatedclose()callsfclose()and nulls the socket;isConnected()reports whether the socket is set
Copy the structure, replace the I/O calls with your transport's primitives, and you have a new transport.
Example — InMemoryTransport for tests
A reusable mock that satisfies the contract without any real socket.
Lives at tests/Unit/Helpers/InMemoryTransport.php:
final class InMemoryTransport implements ClientTransportInterface
{
/** @var string[] */ public array $sentMessages = [];
/** @var string[] */ private array $responseQueue = [];
public ?string $connectedHost = null;
public ?int $connectedPort = null;
public ?float $connectTimeout = null;
public int $receiveBufferSize = 65535;
private bool $connected = false;
public function connect(string $host, int $port, null|float $timeout = null): void
{
$this->connectedHost = $host;
$this->connectedPort = $port;
$this->connectTimeout = $timeout;
$this->connected = true;
}
public function send(string $data): void
{
if (! $this->connected) {
throw new ConnectionException('Not connected');
}
$this->sentMessages[] = $data;
}
public function receive(): string
{
if (! $this->connected) {
throw new ConnectionException('Not connected');
}
if ($this->responseQueue === []) {
throw new ConnectionException('Response queue exhausted');
}
return array_shift($this->responseQueue);
}
public function setReceiveBufferSize(int $size): void { $this->receiveBufferSize = $size; }
public function close(): void { $this->connected = false; }
public function isConnected(): bool { return $this->connected; }
public function queueResponse(string $framedMessage): void
{
$this->responseQueue[] = $framedMessage;
}
}
Use it with the builder to assert what the client sends without spinning up a real server:
$transport = new InMemoryTransport();
$transport->queueResponse($cannedHelloAckBytes);
$transport->queueResponse($cannedOpenSecureChannelResponse);
// ... pre-load every response your test will trigger ...
$client = ClientBuilder::create()
->setTransport($transport)
->connect('opc.tcp://test:4840');
// later …
expect($transport->sentMessages[0])->toStartWith("HELF");
A real implementation needs to pre-load the full HEL/ACK + OPN +
CreateSession + ActivateSession flow before connect() returns —
that's why the library ships a higher-level MockClient in
tests/Unit/MockClientSimplifiedTest.php for tests that don't care
about the wire layer.
Contract details
A correct implementation observes the following invariants:
connect()opens the transport. Subsequentconnect()without a priorclose()is implementation-defined; document what your transport does.send()writes the entire payload or throwsConnectionException. Partial writes that complete on retry are fine; partial writes that silently truncate the message are not.receive()returns exactly one complete OPC UA framed message with its 8-byte header. It blocks until that message arrives or the transport errors. The header bytes 4-7 carry the message size (little-endianuint32).setReceiveBufferSize()caps the largest message the transport is willing to receive. Transports that don't impose a wire-level size limit (e.g. HTTPS with chunked bodies) may treat this as advisory — but they must honour the size on the OPC UA path or the server-side negotiation becomes meaningless.close()is idempotent — calls after the first are a no-op.isConnected()reflects reality — afterclose()it returnsfalse; after a transport-level disconnect (Connection reset by peer, etc.) it should also returnfalseon the next call, even if the implementation discovers the disconnect lazily insidereceive().
The transport is not required to be thread-safe — the Client
owns a single transport instance and uses it from a single thread.
Exceptions
Throw the right exception type:
ConnectionException— transport-level failures: socket can't open, read/write returned a permanent error, peer closed the channel, timeout reading data.ProtocolException— framing failures: the 8-byte header says the message is 1 GB, the message-type field isn't a recognized 3-letter ASCII code, the chunk type byte is invalid.
Higher-level OPC UA semantics (Bad_ServiceUnsupported,
Bad_NodeIdUnknown) belong upstream of the transport — the transport
just returns the bytes.
What to read next
- Modules — the other extension point. Custom modules register methods on the client; custom transports change how those methods reach the wire.
- Builder API — every setter on
ClientBuilder, includingsetTransport(). - Connection · Opening and closing — the lifecycle the transport participates in.