opcua-session-manager · master
Docs · Recipes

Persistent sessions in Laravel

The canonical Laravel pattern: bind ManagedClient as a service-container singleton, configure once, inject everywhere. Sessions persist across PHP-FPM requests because the daemon outlives them.

The session manager exists exactly for this pattern: every Laravel HTTP request, every console command, every queue job talks to the daemon over IPC, the daemon holds the OPC UA session, the request budget pays an IPC round-trip instead of a full OPC UA handshake.

This recipe shows the manual integration. For a turnkey package, use php-opcua/laravel-opcua — it wraps everything here in a service provider + facade.

1 — Daemon as a service

Run the daemon as a system service. See Daemon · Running as a service for the systemd / supervisor / Docker recipes. The endpoint and auth token need to be reachable from your PHP-FPM workers.

.env:

text .env
OPCUA_SOCKET_PATH=/var/run/opcua/sessions.sock
OPCUA_AUTH_TOKEN=long-random-string-here
OPCUA_ENDPOINT=opc.tcp://plc.local:4840
OPCUA_SECURITY_POLICY=http://opcfoundation.org/UA/SecurityPolicy#Basic256Sha256
OPCUA_SECURITY_MODE=3
OPCUA_USERNAME=integrations
OPCUA_PASSWORD=secret
OPCUA_CLIENT_CERT=/etc/opcua/client.pem
OPCUA_CLIENT_KEY=/etc/opcua/client.key

2 — Service provider binding

Bind OpcUaClientInterface to the container as a singleton — one client per request, configured exactly once, injected through DI.

php app/Providers/OpcUaServiceProvider.php
namespace App\Providers;

use Illuminate\Contracts\Foundation\Application;
use Illuminate\Support\ServiceProvider;
use PhpOpcua\Client\OpcUaClientInterface;
use PhpOpcua\Client\Security\SecurityMode;
use PhpOpcua\Client\Security\SecurityPolicy;
use PhpOpcua\SessionManager\Client\ManagedClient;

class OpcUaServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->singleton(OpcUaClientInterface::class, function (Application $app) {
            $client = (new ManagedClient(
                socketPath: config('opcua.socket_path'),
                timeout:    (float) config('opcua.timeout', 30.0),
                authToken:  config('opcua.auth_token'),
            ))
                ->setLogger($app['log']->channel('opcua'))
                ->setSecurityPolicy(SecurityPolicy::from(config('opcua.security_policy')))
                ->setSecurityMode(SecurityMode::from((int) config('opcua.security_mode')))
                ->setUserCredentials(config('opcua.username'), config('opcua.password'))
                ->setClientCertificate(
                    config('opcua.client_cert'),
                    config('opcua.client_key'),
                )
                ->setTimeout((float) config('opcua.opcua_timeout', 10.0))
                ->setAutoRetry((int) config('opcua.auto_retry', 3));

            $client->connect(config('opcua.endpoint'));

            return $client;
        });
    }
}

Register the provider in config/app.php. Add the config file:

php config/opcua.php
return [
    'socket_path'     => env('OPCUA_SOCKET_PATH', '/tmp/opcua-session-manager.sock'),
    'auth_token'      => env('OPCUA_AUTH_TOKEN'),
    'timeout'         => env('OPCUA_IPC_TIMEOUT', 30.0),
    'endpoint'        => env('OPCUA_ENDPOINT'),
    'security_policy' => env('OPCUA_SECURITY_POLICY', 'http://opcfoundation.org/UA/SecurityPolicy#None'),
    'security_mode'   => env('OPCUA_SECURITY_MODE', 1),
    'username'        => env('OPCUA_USERNAME'),
    'password'        => env('OPCUA_PASSWORD'),
    'client_cert'     => env('OPCUA_CLIENT_CERT'),
    'client_key'      => env('OPCUA_CLIENT_KEY'),
    'opcua_timeout'   => env('OPCUA_TIMEOUT', 10.0),
    'auto_retry'      => env('OPCUA_AUTO_RETRY', 3),
];

3 — Inject and use

Anywhere in your application:

php app/Services/PlcSpeedReader.php
namespace App\Services;

use PhpOpcua\Client\OpcUaClientInterface;
use PhpOpcua\Client\Types\StatusCode;

readonly class PlcSpeedReader
{
    public function __construct(
        private OpcUaClientInterface $opcua,
    ) {}

    public function currentSpeed(): float
    {
        $dv = $this->opcua->read('ns=2;s=Devices/PLC/Speed');

        if (! StatusCode::isGood($dv->statusCode)) {
            throw new RuntimeException('PLC speed read failed: ' . StatusCode::getName($dv->statusCode));
        }

        return $dv->getValue();
    }
}

Laravel auto-resolves the constructor argument from the container. The first request after a daemon restart pays the OPC UA handshake; every subsequent request — within or across PHP-FPM workers — reuses the daemon-held session.

4 — Verify the reuse

In a debug controller:

php routes/web.php
Route::get('/opcua/debug', function (OpcUaClientInterface $client) {
    return [
        'sessionId'  => $client instanceof \PhpOpcua\SessionManager\Client\ManagedClient
            ? $client->getSessionId()
            : null,
        'reused'     => $client instanceof \PhpOpcua\SessionManager\Client\ManagedClient
            ? $client->wasSessionReused()
            : null,
        'connected'  => $client->isConnected(),
    ];
});

Hit the route once: reused: false. Hit it again: reused: true, same sessionId. That is the daemon working as designed.

Queue workers

The same binding works for queue workers (php artisan queue:work) — each worker process gets the singleton, every job in the worker talks through the cached ManagedClient. Per-job IPC cost is one round-trip per OPC UA operation; session reuse across jobs is automatic.

For long-running workers (Horizon supervisors), the worker process itself is long-lived; the singleton survives between jobs.

Console commands

For Artisan commands:

php app/Console/Commands/ReadSpeed.php
namespace App\Console\Commands;

use App\Services\PlcSpeedReader;
use Illuminate\Console\Command;

class ReadSpeed extends Command
{
    protected $signature = 'opcua:read-speed';

    public function handle(PlcSpeedReader $reader): int
    {
        $this->line("Speed: " . $reader->currentSpeed());
        return self::SUCCESS;
    }
}

The command boots the container, the singleton constructor calls connect(), the read returns, the process exits. The OPC UA session persists on the daemon side for the next request.

Reconnect when the session goes stale

If the daemon was restarted between two Laravel requests, the next call raises a ConnectionException whose message begins with "Session expired or not found" (the client-side wrapper around the daemon's session_not_found wire token). Wrap calls with a middleware-style reconnect:

php app/Services/ResilientOpcUa.php
namespace App\Services;

use PhpOpcua\Client\Exception\ConnectionException;
use PhpOpcua\Client\OpcUaClientInterface;
use PhpOpcua\SessionManager\Client\ManagedClient;

readonly class ResilientOpcUa
{
    public function __construct(
        private OpcUaClientInterface $client,
        private string $endpoint,
    ) {}

    public function call(callable $fn): mixed
    {
        try {
            return $fn($this->client);
        } catch (ConnectionException $e) {
            if (! str_starts_with($e->getMessage(), 'Session expired or not found')) {
                throw $e;
            }
            if ($this->client instanceof ManagedClient) {
                $this->client->connect($this->endpoint);
            }
            return $fn($this->client);
        }
    }
}

Use it for any call that crosses a worker / daemon restart boundary. For broader recovery (channel breaks, OPC UA disconnects), see Recipes · Recovery and reconnect.

When this pattern is the wrong fit

  • Multi-tenant apps with per-tenant credentials. Each tenant needs its own ManagedClient configured with its own credentials. Bind as a factory keyed by tenant ID, not as a singleton.
  • CLI scripts that exit immediately. The singleton-per-request model has no value when the process is also the request. Use the direct opcua-client instead.
  • Strict per-request isolation requirements. The session singleton may carry state across requests (subscriptions, cache). When that's a problem, use connectForceNew() per request — at the cost of the handshake every time.