opcua-session-manager · master
Docs · Extensibility

Third-party modules

Custom ServiceModules registered on the daemon are reachable from ManagedClient out of the box. ::__call() routes unknown methods through invoke; the Wire registry handles the typed payloads.

opcua-client exposes a module system: ServiceModule subclasses register methods on the client and can ship their own DTOs. When the daemon embeds the client, those modules are available server-side; when ManagedClient reaches a method it does not declare, the daemon's invoke path handles dispatch — no daemon patching required.

This page is the integration pattern. For writing the module itself, see the opcua-client module-system reference.

The integration loop

  1. 01

    Write a `ServiceModule`

    in your application (or in a reusable package). The module declares its methods and DTOs.

  2. 02

    Make sure the DTOs implement `WireSerializable`.

    This is what lets typed args and results cross the IPC boundary safely.

  3. 03

    Register the module on the daemon's client

    at daemon construction time. The daemon embeds the client; you control what modules it loads.

  4. 04

    Call the method via `ManagedClient`

    as you would any other. __call() routes the unknown name through invoke; the daemon dispatches to your handler; the typed result comes back.

No changes to CommandHandler, no entries added to ALLOWED_METHODS, no custom ParamDeserializerInterface.

A worked example

Suppose your application ships a QueryFirstModule that exposes a queryFirst() method against servers that implement OPC UA's Query service set (not part of the default modules).

The module side (in your package or app code)

php examples/QueryFirstModule.php
namespace App\Opcua\Query;

use PhpOpcua\Client\Module\ServiceModule;
use PhpOpcua\Client\Wire\WireTypeRegistry;

final class QueryFirstModule extends ServiceModule
{
    public function register(): void
    {
        // Methods are registered on the concrete Client — ServiceModule
        // exposes it as $this->client. The kernel ($this->kernel) is for
        // infrastructure operations only (executeWithRetry, dispatch, …).
        $this->client->registerMethod('queryFirst', $this->handleQueryFirst(...));
    }

    public function handleQueryFirst(array $filter, int $maxNodes): QueryFirstResult
    {
        // Issue the OPC UA Query service via the kernel; return a typed DTO.
        // …
    }

    public function registerWireTypes(WireTypeRegistry $registry): void
    {
        $registry->register(QueryFirstResult::wireTypeId(), QueryFirstResult::class);
    }
}

QueryFirstResult is a final readonly class that implements WireSerializable. See opcua-client wire serialization for the contract.

The daemon side

Module registration happens before the daemon is constructed. You write a small custom launcher that wires the module into the ClientBuilder the daemon will use:

php bin/my-opcua-daemon
#!/usr/bin/env php
<?php
require __DIR__ . '/../vendor/autoload.php';

use PhpOpcua\SessionManager\Daemon\SessionManagerDaemon;

// SessionManagerDaemon does NOT currently expose a clientFactory
// argument: every Client it constructs is built internally by
// CommandHandler::buildClientFromConfig() with a vanilla
// ClientBuilder. To make a module reachable through `invoke` on
// the packaged daemon, the module has to be registered by the
// underlying opcua-client at the autoloader level — for example
// via ClientBuilder's default-module hook (see opcua-client docs).
// If your module cannot be wired that way, the only option today
// is to fork SessionManagerDaemon / CommandHandler and inject a
// custom ClientBuilder factory in buildClientFromConfig().

$daemon = new SessionManagerDaemon(
    socketPath: '/var/run/opcua/sessions.sock',
);

$daemon->run();

Warning

There is no clientFactory parameter on SessionManagerDaemon in the current release. Attempting new SessionManagerDaemon(..., clientFactory: fn() => ...) fails with "Unknown named parameter $clientFactory". Treat any third-party module registration as requiring upstream coordination (a default-module hook on ClientBuilder) or a local fork until a public extension surface ships.

The application side

php examples/call-third-party.php
use PhpOpcua\SessionManager\Client\ManagedClient;

$client = new ManagedClient('/var/run/opcua/sessions.sock');
$client->connect('opc.tcp://plc.local:4840');

// queryFirst() is not on OpcUaClientInterface — __call() routes
// it through invoke automatically.
$result = $client->queryFirst($filter, maxNodes: 100);

// $result is a QueryFirstResult instance, fully decoded by the
// Wire registry round-trip.

The application never sees the IPC plumbing. It calls $client->queryFirst(); the result is a typed PHP object on its side; the actual OPC UA call ran in the daemon process.

How ManagedClient::__call() decides to use invoke

When the application calls a method ManagedClient does not declare:

  1. 01

    `__call()` consults `hasMethod($name)`.

    This in turn consults the cached describe response, populated on first introspective call. If the daemon's client did not register a method by that name, __call() raises BadMethodCallException — the daemon never sees the call.

  2. 02

    If the method exists, `__call()` routes through `invoke`.

    Typed args are Wire-encoded against the registry built from the daemon's describe response. The result comes back wire-encoded and is decoded the same way.

  3. 03

    Cached `describe` becomes stale only on IPC disconnect.

    Within a session, the method set is fixed. Cross-session, a daemon restart with a different module list refreshes the describe on the next connect().

Why this matters

Before v4.2.0, the only way to expose a third-party method through the daemon was to patch CommandHandler::ALLOWED_METHODS and write a matching entry in the param deserializer. That coupled module authors to the session-manager package — a non-starter for extension authors.

The Wire-registry-gated invoke path makes third-party modules transparent: an extension that follows the WireSerializable contract works end-to-end without any session-manager-specific code.

Constraints

  • DTOs must implement WireSerializable. Plain arrays travel too — JSON handles them — but typed objects need the discriminator-based decoding the Wire registry provides.
  • Server-side dispatch is synchronous. Each invoke call blocks the daemon's IPC handling for that connection until the underlying OPC UA call returns. Long-running method handlers hold up other commands on the same connection (not on the process — other connections are unaffected).
  • No streaming results. A method that wants to push many results needs to return them all at once. For streaming, subscriptions + auto-publish is the canonical pattern.

When to fall back to query + a custom deserializer

Two cases:

  • You are patching a built-in method whose param shape changed and the daemon version in production cannot yet be upgraded. Register a ParamDeserializerInterface for the patched method.
  • You are integrating a non-PHP client that prefers the legacy untyped query shape. Write a ParamDeserializerInterface so the daemon decodes whatever your client sends.

For new code in PHP, invoke is the path of least resistance.