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:
- Per-tenant connection config.
- Per-tenant credentials and trust stores.
- Tenant-aware listeners (data, alarms).
- 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:
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:
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:
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:
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:
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
/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):
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:
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:
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
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";
}
}
doctrine:
orm:
filters:
tenant:
class: App\Doctrine\Filter\TenantFilter
enabled: false
Enable per-request:
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:
# /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:
OPCUA_SOCKET_PATH=/var/run/opcua/acme.sock
OPCUA_AUTH_TOKEN=...
…with the bundle config pointing at %env(OPCUA_SOCKET_PATH)%.
Enable per tenant:
systemctl enable --now [email protected]
systemctl enable --now [email protected]
Onboarding command
#[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;
}
}
Where to read next
- Using companion specs — type-aware browse for tenant-specific tag taxonomies.
- Production deployment — shipping the whole multi-tenant pattern.