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

Multi-plant tenant

Multi-tenant Symfony apps with per-tenant OPC UA endpoints, isolated trust stores, scoped persistence, and Doctrine global filters.

A single Symfony app serving many plants, each with its own OPC UA infrastructure. Four hard parts:

  1. Per-tenant connection config.
  2. Per-tenant credentials and trust stores.
  3. Tenant-aware listeners (data, alarms).
  4. Per-tenant data isolation in Doctrine.

Tenancy model

This recipe assumes you've adopted a tenancy strategy — hakam/multi-tenancy-bundle, a custom Doctrine filter, or per-tenant Symfony kernels. The patterns adapt; the bundle stays the same.

Per-tenant connection — static config

For 10-100 known tenants:

text bundle config
php_opcua_symfony_opcua:
    connections:
        plc-tenant-acme:
            endpoint:           '%env(OPCUA_ACME_ENDPOINT)%'
            security_policy:    Basic256Sha256
            security_mode:      SignAndEncrypt
            client_certificate: '/etc/opcua/tenants/acme/cert.pem'
            client_key:         '/etc/opcua/tenants/acme/cert.key'
            username:           '%env(OPCUA_ACME_USER)%'
            password:           '%env(secret:OPCUA_ACME_PASS)%'
            trust_store_path:   '/var/lib/opcua/tenants/acme/trust'
        plc-tenant-globex:
            # ... same shape

A resolver service:

php src/Opcua/ConnectionResolver.php
namespace App\Opcua;

use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;

final class ConnectionResolver
{
    public function __construct(private TokenStorageInterface $tokens) {}

    public function forCurrentTenant(): string
    {
        $user = $this->tokens->getToken()?->getUser();
        if (!$user instanceof User) {
            throw new \DomainException('No authenticated user');
        }
        return 'plc-tenant-' . $user->getTenant()->getSlug();
    }
}

Controllers use it:

php usage
public function read(ConnectionResolver $r): JsonResponse
{
    $conn = $r->forCurrentTenant();
    $dv = $this->opcua->connect($conn)->read('ns=2;s=Speed');
    return $this->json(['value' => $dv->getValue()]);
}

Per-tenant — dynamic via Doctrine

For 100+ tenants from a tenant_plc_configs table:

php src/Entity/TenantPlcConfig.php
namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
class TenantPlcConfig
{
    #[ORM\Id, ORM\GeneratedValue, ORM\Column(type: 'integer')]
    public ?int $id = null;

    #[ORM\OneToOne(targetEntity: Tenant::class)]
    public Tenant $tenant;

    #[ORM\Column(type: 'string')]
    public string $endpoint;

    #[ORM\Column(type: 'string', length: 50)]
    public string $securityPolicy = 'Basic256Sha256';

    #[ORM\Column(type: 'string', length: 50)]
    public string $securityMode = 'SignAndEncrypt';

    #[ORM\Column(type: 'string')]
    public string $certPath;

    #[ORM\Column(type: 'string')]
    public string $keyPath;

    #[ORM\Column(type: 'string', length: 100, nullable: true)]
    public ?string $username = null;

    #[ORM\Column(type: 'string', nullable: true)]
    #[ORM\Column(type: 'string', options: ['comment' => 'encrypted via doctrine extension'])]
    public ?string $passwordEncrypted = null;

    public function toOpcuaConfig(\App\Security\Encryptor $encryptor): array
    {
        return [
            'endpoint'           => $this->endpoint,
            'security_policy'    => $this->securityPolicy,
            'security_mode'      => $this->securityMode,
            'client_certificate' => $this->certPath,
            'client_key'         => $this->keyPath,
            'username'           => $this->username,
            'password'           => $this->passwordEncrypted ? $encryptor->decrypt($this->passwordEncrypted) : null,
            'trust_store_path'   => "/var/lib/opcua/tenants/{$this->tenant->getSlug()}/trust",
            'timeout'            => 8.0,
        ];
    }
}

Fleet reader service:

php src/Service/FleetReader.php
namespace App\Service;

use App\Repository\TenantPlcConfigRepository;
use App\Security\Encryptor;
use PhpOpcua\SymfonyOpcua\OpcuaManager;

final class FleetReader
{
    public function __construct(
        private OpcuaManager $opcua,
        private TenantPlcConfigRepository $repo,
        private Encryptor $encryptor,
    ) {}

    public function speedFor(string $tenantSlug): ?float
    {
        $config = $this->repo->findOneByTenantSlug($tenantSlug);
        if ($config === null) return null;

        $client = $this->opcua->connectTo(
            $config->endpoint,
            $config->toOpcuaConfig($this->encryptor),
            as: "tenant:$tenantSlug",
        );

        return (float) $client->read('ns=2;s=Speed')->getValue();
    }
}

Per-tenant trust stores

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

Per-tenant pin (via the opcua-cli companion, or your own app:trust:add command that wraps FileTrustStore):

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

The bundle itself does not ship an opcua:trust:* Symfony command — trust-store management is a separate tool.

Permissions:

bash 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 trust store doesn't affect others.

Tenant-aware listeners

The event arrives carrying the live $event->client (OpcUaClientInterface) instance — not a connection name. Resolve the tenant by comparing client identities against the manager's named connections:

php tenant-aware listener
namespace App\EventListener;

use App\Entity\Tenant;
use App\Repository\TenantRepository;
use Doctrine\ORM\EntityManagerInterface;
use PhpOpcua\Client\Event\DataChangeReceived;
use PhpOpcua\Client\OpcUaClientInterface;
use PhpOpcua\SymfonyOpcua\OpcuaManager;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;

final class StoreTenantReading
{
    /** Cached lookup: client instance hash → tenant slug. */
    private array $clientMap = [];

    public function __construct(
        private OpcuaManager $opcua,
        private TenantRepository $tenants,
        private EntityManagerInterface $em,
        private array $tenantConnections,   // ['acme' => 'tenant:acme', ...]
    ) {}

    #[AsEventListener]
    public function __invoke(DataChangeReceived $event): void
    {
        $tenant = $this->tenantFromClient($event->client);
        if ($tenant === null) {
            return;
        }

        $reading = (new \App\Entity\PlcReading())
            ->setTenant($tenant)
            ->setClientHandle($event->clientHandle)
            ->setValue($event->dataValue->getValue())
            ->setStatusCode($event->dataValue->statusCode)
            ->setSourceAt($event->dataValue->sourceTimestamp);

        $this->em->persist($reading);
        $this->em->flush();
    }

    private function tenantFromClient(OpcUaClientInterface $client): ?Tenant
    {
        $key = spl_object_hash($client);
        if (isset($this->clientMap[$key])) {
            return $this->tenants->findOneBy(['slug' => $this->clientMap[$key]]);
        }

        foreach ($this->tenantConnections as $slug => $connectionName) {
            if ($this->opcua->connection($connectionName) === $client) {
                $this->clientMap[$key] = $slug;
                return $this->tenants->findOneBy(['slug' => $slug]);
            }
        }

        return null;
    }
}

Per-tenant data isolation — Doctrine filter

php src/Doctrine/Filter/TenantFilter.php
namespace App\Doctrine\Filter;

use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Query\Filter\SQLFilter;

final class TenantFilter extends SQLFilter
{
    public function addFilterConstraint(ClassMetadata $target, string $alias): string
    {
        if (!$target->hasAssociation('tenant')) {
            return '';
        }
        $tenantId = (int) $this->getParameter('tenantId');
        return "$alias.tenant_id = $tenantId";
    }
}
text config/packages/doctrine.yaml
doctrine:
    orm:
        filters:
            tenant:
                class: App\Doctrine\Filter\TenantFilter
                enabled: false

Enable per-request:

php kernel listener
namespace App\EventListener;

use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;

final class EnableTenantFilter
{
    public function __construct(
        private EntityManagerInterface $em,
        private TokenStorageInterface $tokens,
    ) {}

    #[AsEventListener(priority: -10)]
    public function __invoke(RequestEvent $event): void
    {
        $user = $this->tokens->getToken()?->getUser();
        if ($user === null || !method_exists($user, 'getTenant')) {
            return;
        }

        $filter = $this->em->getFilters()->enable('tenant');
        $filter->setParameter('tenantId', $user->getTenant()->getId());
    }
}

Per-tenant daemons (hard isolation)

For hard tenant isolation, one daemon per tenant:

text systemd template
# /etc/systemd/system/[email protected]
[Service]
User=opcua-%i
Group=opcua-%i
WorkingDirectory=/var/www/html
EnvironmentFile=/etc/opcua/tenants/%i.env
ExecStart=/usr/bin/php /var/www/html/bin/console opcua:session

/etc/opcua/tenants/acme.env:

bash env
OPCUA_SOCKET_PATH=/var/run/opcua/acme.sock
OPCUA_AUTH_TOKEN=...

…with the bundle config pointing at %env(OPCUA_SOCKET_PATH)%.

Enable per tenant:

bash enable per tenant
systemctl enable --now [email protected]
systemctl enable --now [email protected]

Onboarding command

php src/Command/OnboardTenantCommand.php
#[AsCommand(name: 'app:tenant:onboard')]
final class OnboardTenantCommand extends Command
{
    protected function configure(): void
    {
        $this->addArgument('slug', InputArgument::REQUIRED);
        $this->addArgument('endpoint', InputArgument::REQUIRED);
        $this->addArgument('username', InputArgument::REQUIRED);
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $slug = $input->getArgument('slug');
        $base = "/var/lib/opcua/tenants/{$slug}";

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

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

        // 3. Pin server cert via the programmatic trust-store API
        $trustStore = new \PhpOpcua\Client\TrustStore\FileTrustStore("$base/trust");
        // Fetch the server cert (e.g. with a `None`-secured discovery
        // probe) and call $trustStore->trust($der); — see the
        // opcua-client trust-store docs for the discovery snippet.

        // 4. Persist config to DB ...
        $output->writeln("Tenant $slug onboarded");
        return Command::SUCCESS;
    }
}
Documentation