laravel-opcua · v4.3.x
Docs · Recipes

Multi-plant tenant

Multi-tenant Laravel apps with per-tenant OPC UA endpoints, isolated trust stores, scoped persistence, and tenant-aware listeners. The complete shape.

A single Laravel app serving many plants, each with its own OPC UA infrastructure. This recipe covers the four hard parts:

  1. Per-tenant connection config.
  2. Per-tenant credentials and trust stores.
  3. Tenant-aware listeners.
  4. Per-tenant data isolation in the persistence layer.

Tenancy model

The recipe assumes you've already adopted a tenancy approach — stancl/tenancy, spatie/laravel-multitenancy, or a homegrown tenant_id-on-every-row pattern.

This page is OPC UA-specific. Pick your tenancy library separately; the patterns adapt to all of them.

Per-tenant connection config

Approach A — static config

For a small, known set of tenants (10-100), put them all in config/opcua.php:

php static tenants
'connections' => [
    'plc-tenant-acme' => [
        'endpoint'         => env('OPCUA_ACME_ENDPOINT'),
        'security_policy'  => 'Basic256Sha256',
        'security_mode'    => 'SignAndEncrypt',
        'client_cert_path' => '/etc/opcua/tenants/acme/cert.pem',
        'client_key_path'  => '/etc/opcua/tenants/acme/cert.key',
        'username'         => env('OPCUA_ACME_USER'),
        'password'         => env('OPCUA_ACME_PASS'),
        'trust_store_path' => '/var/lib/opcua/tenants/acme/trust',
    ],
    'plc-tenant-globex' => [
        'endpoint'         => env('OPCUA_GLOBEX_ENDPOINT'),
        // ... mirror structure
    ],
],

A helper to resolve the current tenant:

php resolver
namespace App\Services;

class OpcuaConnectionResolver
{
    public function forCurrentTenant(): string
    {
        $tenantSlug = tenant()->slug ?? request()->user()->tenant->slug;

        return "plc-tenant-{$tenantSlug}";
    }
}

…used everywhere:

php usage
$conn = app(OpcuaConnectionResolver::class)->forCurrentTenant();
$dv   = Opcua::connection($conn)->read('ns=2;s=Speed');

Approach B — dynamic per-tenant

For a large or growing tenant set, store config in a database table and use connectTo():

php dynamic
namespace App\Models;

class TenantPlcConfig extends Model
{
    protected $guarded = [];
    protected $casts   = ['password' => 'encrypted'];

    public function toOpcuaConfig(): array
    {
        return [
            'endpoint'         => $this->endpoint,
            'security_policy'  => $this->security_policy,
            'security_mode'    => $this->security_mode,
            'client_cert_path' => $this->cert_path,
            'client_key_path'  => $this->key_path,
            'username'         => $this->username,
            'password'         => $this->password,
            'trust_store_path' => "/var/lib/opcua/tenants/{$this->tenant->slug}/trust",
        ];
    }
}
php usage — dynamic
$config = tenant()->plcConfigs()->first()->toOpcuaConfig();
$client = Opcua::connectTo($config);
$dv     = $client->read('ns=2;s=Speed');

encrypted cast keeps the password at rest encrypted using Laravel's APP_KEY. The decrypted value lives in memory only.

Per-tenant trust stores

Each tenant has its own pinned server certs:

bash filesystem
/var/lib/opcua/tenants/
├── acme/
   └── trust/
       └── <fingerprint>.pem
├── globex/
   └── trust/
       └── <fingerprint>.pem
└── soylent/
    └── trust/
        └── <fingerprint>.pem

Add for a specific tenant — use the companion opcua-cli tool (the Laravel package doesn't ship opcua:trust:add):

bash terminal
OPCUA_TRUST_STORE_PATH=/var/lib/opcua/tenants/acme/trust \
  vendor/bin/opcua-cli trust:add opc.tcp://acme-plc.factory.local:4840

Permissions:

bash terminal — perms
sudo chown -R www-data:www-data /var/lib/opcua/tenants/
sudo chmod 750 /var/lib/opcua/tenants/
sudo find /var/lib/opcua/tenants -type d -exec chmod 750 {} \;
sudo find /var/lib/opcua/tenants -type f -exec chmod 640 {} \;

A compromised tenant's trust store doesn't affect others.

Tenant-aware listeners

The event arrives without tenant context. The listener resolves from the connection name:

php tenant-aware listener
namespace App\Listeners;

use App\Models\{PlcReading, Tenant};
use Illuminate\Contracts\Queue\ShouldQueue;
use PhpOpcua\Client\Event\DataChangeReceived;

class StoreReadingForTenant implements ShouldQueue
{
    public string $queue = 'opcua-data';

    /**
     * Build a clientHandle => tenantSlug map at subscription time.
     * The handle is what the event carries — the connection name is not.
     */
    private const HANDLE_TENANT_MAP = [
        // 1000-1999 = ACME, 2000-2999 = Globex, …
        // Bucket the ranges any way that fits your scheme.
    ];

    public function handle(DataChangeReceived $event): void
    {
        $slug = $this->slugFromHandle($event->clientHandle);
        $tenant = $slug ? Tenant::where('slug', $slug)->first() : null;
        if (! $tenant) {
            \Log::warning("Unknown tenant for handle {$event->clientHandle}");
            return;
        }

        $tenant->run(function () use ($event) {
            PlcReading::create([
                'client_handle' => $event->clientHandle,
                'value'         => $event->dataValue->getValue(),
                'status_code'   => $event->dataValue->statusCode,
                'source_at'     => $event->dataValue->sourceTimestamp,
            ]);
        });
    }

    private function slugFromHandle(int $handle): ?string
    {
        return match (true) {
            $handle >= 1000 && $handle < 2000 => 'acme',
            $handle >= 2000 && $handle < 3000 => 'globex',
            default                            => null,
        };
    }
}

$tenant->run(...) is the multi-tenancy library's "execute-in-tenant-context" wrapper. The closure runs with the tenant's database connection scoped, so PlcReading::create lands in the right schema.

For single-database tenancy (where tenant_id is a column), adapt to:

php single-db variant
PlcReading::create([
    'tenant_id'     => $tenant->id,
    'client_handle' => $event->clientHandle,
    // ...
]);

Per-tenant daemons

For hard tenant isolation, run one daemon per tenant:

text systemd template
[Unit]
Description=OPC UA daemon for tenant %i
After=network-online.target

[Service]
User=opcua-%i
Group=opcua-%i
ExecStart=/usr/bin/php /var/www/html/artisan opcua:session
# socket_path, allowed_cert_dirs and auth_token come from
# /var/www/html-tenant-%i/config/opcua.php — one Laravel install per
# tenant. There are no --socket-path / --allowed-cert-dirs /
# --auth-token CLI flags on opcua:session.
Environment=APP_BASE_PATH=/var/www/html-tenant-%i
Restart=on-failure

[Install]
WantedBy=multi-user.target

Saved as /etc/systemd/system/[email protected], then enabled per tenant:

bash terminal
systemctl enable [email protected]
systemctl enable [email protected]
systemctl start [email protected]
systemctl start [email protected]

Per-tenant Unix user means a compromise of one tenant's daemon can't touch another's. Worth it for high-stakes deployments.

The Laravel config points each tenant connection at the right daemon:

php per-daemon socket
// Per-tenant Laravel install — config/opcua.php for the ACME tenant:
'session_manager' => [
    'socket_path' => '/var/run/opcua/tenants/acme/sessions.sock',
    // ...
],
'connections' => [
    'plc' => [
        'endpoint' => 'opc.tcp://acme-plc.factory.local:4840',
        // ...
    ],
],

// Per-tenant Laravel install — config/opcua.php for the Globex tenant:
// (separate Laravel install, separate config file)
'session_manager' => [
    'socket_path' => '/var/run/opcua/tenants/globex/sessions.sock',
],
'connections' => [
    'plc' => [
        'endpoint' => 'opc.tcp://globex-plc.factory.local:4840',
    ],
],

Tenant onboarding command

Automate new-tenant setup:

php OnboardPlcTenant command
class OnboardPlcTenant extends Command
{
    protected $signature = 'plc:onboard
        {tenant : Tenant slug}
        {endpoint : OPC UA endpoint}
        {username : OPC UA username}
        {password : OPC UA password}';

    public function handle(): int
    {
        $slug = $this->argument('tenant');
        $base = "/var/lib/opcua/tenants/{$slug}";

        // 1. Create directories
        mkdir("$base/trust", recursive: true);

        // 2. Pin the server cert (using opcua-cli — laravel-opcua does not
        // ship an opcua:trust:add command).
        $endpoint = $this->argument('endpoint');
        $env = ['OPCUA_TRUST_STORE_PATH' => "$base/trust"];
        $cmd = ['vendor/bin/opcua-cli', 'trust:add', '--force', $endpoint];
        $proc = new \Symfony\Component\Process\Process($cmd, env: $env);
        $proc->mustRun();

        // 3. Generate a client cert
        $this->generateClientCert("$base/cert.pem", "$base/cert.key", $slug);

        // 4. Persist the tenant config
        TenantPlcConfig::create([
            'tenant_id'        => Tenant::where('slug', $slug)->firstOrFail()->id,
            'endpoint'         => $this->argument('endpoint'),
            'username'         => $this->argument('username'),
            'password'         => $this->argument('password'),
            'cert_path'        => "$base/cert.pem",
            'key_path'         => "$base/cert.key",
            'security_policy'  => 'Basic256Sha256',
            'security_mode'    => 'SignAndEncrypt',
        ]);

        $this->info("Onboarded tenant {$slug}");
        $this->warn("Now register the client cert on the OPC UA server!");
        return self::SUCCESS;
    }

    private function generateClientCert(string $pemPath, string $keyPath, string $slug): void
    {
        \Process::run([
            'openssl', 'req', '-x509', '-newkey', 'rsa:2048',
            '-keyout', $keyPath, '-out', $pemPath, '-days', '365', '-nodes',
            '-subj', "/CN=Laravel-OPCUA-{$slug}/O=Acme",
            '-addext', "subjectAltName=URI:urn:laravel-opcua:{$slug}",
        ])->throw();

        chmod($keyPath, 0600);
        chmod($pemPath, 0640);
    }
}

Tenant offboarding

The complement:

php OffboardPlcTenant
class OffboardPlcTenant extends Command
{
    protected $signature = 'plc:offboard {tenant}';

    public function handle(): int
    {
        $slug = $this->argument('tenant');

        // 1. Stop the per-tenant daemon (if applicable)
        \Process::run(['systemctl', 'stop', "opcua-session-manager@{$slug}.service"]);
        \Process::run(['systemctl', 'disable', "opcua-session-manager@{$slug}.service"]);

        // 2. Remove the tenant config
        TenantPlcConfig::whereHas('tenant', fn($q) => $q->where('slug', $slug))->delete();

        // 3. Archive (don't delete) the trust store + certs
        $from = "/var/lib/opcua/tenants/{$slug}";
        $to   = "/var/lib/opcua/archive/" . now()->format('YmdHis') . "-{$slug}";
        rename($from, $to);

        // 4. Tenant data: per-app policy. Often you keep it for audit.
        $this->info("Offboarded {$slug}. Data archived to {$to}");
        return 0;
    }
}

Cost model

Tenants Single daemon Per-tenant daemons
1-10 Recommended Overkill
10-50 Fine if tenants trust each other Recommended for prod
50+ Memory pressure on the daemon Required

Single-daemon CPU: 1-2% per active subscription. Per-tenant-daemon CPU: same, plus IPC overhead per call. Per-tenant-daemon memory: ~50 MB base per daemon.

For 100 tenants on per-tenant daemons, budget ~5 GB RAM just for daemons.