Alarm routing
End-to-end alarm pipeline: subscription, persistence, severity routing via Notifier, acknowledgement endpoint, audit chain. The Symfony-native shape most plants converge on.
A complete alarm pipeline. From subscription → persistence → severity routing (Notifier) → operator UI acknowledgement.
Architecture
OPC UA server Daemon (auto-publish) Symfony
───────────── ───────────────────── ─────────────────
EventNotifier ──► PSR-14: EventNotificationReceived EventDispatcher
│
├─► PersistAlarm (Doctrine)
├─► RouteAlarmToOps (Notifier)
└─► BroadcastAlarm (Mercure)
Operator UI ack
│
▼
AlarmAckController → $client->call(ConditionType, Acknowledge, [...])
│
▼ (server emits)
EventNotificationReceived (isAcked=true)
│
└─► PersistAlarm updates is_acked
Doctrine schema
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ORM\Table(name: 'plc_alarms', indexes: [
new ORM\Index(columns: ['is_active', 'is_acked', 'severity']),
])]
class PlcAlarm
{
#[ORM\Id, ORM\GeneratedValue, ORM\Column(type: 'bigint')]
public ?int $id = null;
/** The clientHandle of the event-monitored item this came from. */
#[ORM\Column(type: 'integer')]
public int $clientHandle;
#[ORM\Column(type: 'string', length: 64, unique: true)]
public string $eventId;
#[ORM\Column(type: 'string', length: 256, nullable: true)]
public ?string $eventType = null;
#[ORM\Column(type: 'string', nullable: true)]
public ?string $sourceName = null;
#[ORM\Column(type: 'integer', nullable: true)]
public ?int $severity = null;
#[ORM\Column(type: 'text', nullable: true)]
public ?string $message = null;
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
public ?\DateTimeImmutable $occurredAt = null;
#[ORM\Column(type: 'boolean')]
public bool $isActive = true;
#[ORM\Column(type: 'boolean')]
public bool $isAcked = false;
}
Plus an ack audit table:
#[ORM\Entity]
class PlcAlarmAck
{
#[ORM\Id, ORM\GeneratedValue, ORM\Column(type: 'integer')]
public ?int $id = null;
#[ORM\ManyToOne(targetEntity: PlcAlarm::class)]
public PlcAlarm $alarm;
#[ORM\Column(type: 'string', length: 100)]
public string $userId;
#[ORM\Column(type: 'text', nullable: true)]
public ?string $comment = null;
#[ORM\Column(type: 'datetime_immutable')]
public \DateTimeImmutable $ackedAt;
}
Subscription setup
php_opcua_symfony_opcua:
session_manager:
auto_publish: true
connections:
default:
endpoint: '%env(OPCUA_ENDPOINT)%'
auto_connect: true
subscriptions:
-
publishing_interval: 1000.0
event_monitored_items:
- node_id: 'ns=0;i=2253'
client_handle: 100
select_fields:
- EventId
- EventType
- SourceName
- Time
- Message
- Severity
- ActiveState/Id
- AckedState/Id
Listeners
Persist
namespace App\EventListener;
use App\Entity\PlcAlarm;
use Doctrine\ORM\EntityManagerInterface;
use PhpOpcua\Client\Event\EventNotificationReceived;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
final class PersistAlarm
{
public function __construct(private EntityManagerInterface $em) {}
#[AsEventListener]
public function __invoke(EventNotificationReceived $event): void
{
$f = $event->eventFields;
$eventId = isset($f['EventId']) ? bin2hex((string) $f['EventId']) : null;
if ($eventId === null) return;
$alarm = $this->em->getRepository(PlcAlarm::class)
->findOneBy(['eventId' => $eventId]) ?? new PlcAlarm();
$alarm->eventId = $eventId;
$alarm->clientHandle = $event->clientHandle;
$alarm->eventType = isset($f['EventType']) ? (string) $f['EventType'] : null;
$alarm->sourceName = $f['SourceName'] ?? null;
$alarm->severity = isset($f['Severity']) ? (int) $f['Severity'] : null;
$alarm->message = isset($f['Message']) ? (string) $f['Message'] : null;
$alarm->occurredAt = $f['Time'] instanceof \DateTimeInterface
? \DateTimeImmutable::createFromInterface($f['Time']) : null;
$alarm->isActive = (bool) ($f['ActiveState/Id'] ?? true);
$alarm->isAcked = (bool) ($f['AckedState/Id'] ?? false);
$this->em->persist($alarm);
$this->em->flush();
}
}
Route
namespace App\EventListener;
use App\Notification\PlcAlarmNotification;
use PhpOpcua\Client\Event\EventNotificationReceived;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\Notifier\NotifierInterface;
use Symfony\Component\Notifier\Recipient\Recipient;
final class RouteAlarm
{
public function __construct(private NotifierInterface $notifier) {}
#[AsEventListener]
public function __invoke(EventNotificationReceived $event): void
{
$f = $event->eventFields;
$severity = isset($f['Severity']) ? (int) $f['Severity'] : 0;
$active = (bool) ($f['ActiveState/Id'] ?? true);
if (!$active || $severity < 400) {
return;
}
$this->notifier->send(
new PlcAlarmNotification(
source: $f['SourceName'] ?? 'unknown',
severity: $severity,
message: (string) ($f['Message'] ?? ''),
),
new Recipient('[email protected]', '+15551234567'),
);
}
}
PlcAlarmNotification is the Notification class — see
Integrations · Notifier. For a
typed alternative, listen on the alarm-derived event
PhpOpcua\Client\Event\AlarmActivated (which carries
$sourceName / $severity / $message as non-null fields)
when an alarm transitions to active.
Broadcast
namespace App\EventListener;
use PhpOpcua\Client\Event\EventNotificationReceived;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
final class BroadcastAlarm
{
public function __construct(private HubInterface $hub) {}
#[AsEventListener]
public function __invoke(EventNotificationReceived $event): void
{
$f = $event->eventFields;
$this->hub->publish(new Update(
topics: ['/plc/alarms'],
data: json_encode([
'event_id' => isset($f['EventId']) ? bin2hex((string) $f['EventId']) : null,
'source' => $f['SourceName'] ?? null,
'severity' => $f['Severity'] ?? null,
'message' => $f['Message'] ?? null,
'is_active' => $f['ActiveState/Id'] ?? null,
'is_acked' => $f['AckedState/Id'] ?? null,
]),
private: true,
));
}
}
The acknowledge endpoint
namespace App\Controller;
use App\Entity\PlcAlarmAck;
use App\Service\AlarmAcknowledgeService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\{JsonResponse, Request};
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
final class AlarmAckController extends AbstractController
{
public function __construct(
private AlarmAcknowledgeService $svc,
private EntityManagerInterface $em,
) {}
#[Route('/api/alarms/{eventId}/ack', methods: ['POST'])]
#[IsGranted('ROLE_OPERATOR')]
public function ack(string $eventId, Request $request): JsonResponse
{
$comment = (string) ($request->toArray()['comment'] ?? '');
try {
$this->svc->acknowledge($eventId, $comment);
} catch (\RuntimeException $e) {
return $this->json(['error' => $e->getMessage()], 422);
}
$audit = (new PlcAlarmAck())
->setAlarm($this->em->getRepository(\App\Entity\PlcAlarm::class)->findOneBy(['eventId' => $eventId]))
->setUserId($this->getUser()->getUserIdentifier())
->setComment($comment)
->setAckedAt(new \DateTimeImmutable());
$this->em->persist($audit);
$this->em->flush();
return $this->json(['status' => 'acked']);
}
}
The ack service
namespace App\Service;
use PhpOpcua\Client\OpcUaClientInterface;
use PhpOpcua\Client\Types\BuiltinType;
final class AlarmAcknowledgeService
{
public function __construct(private OpcUaClientInterface $client) {}
public function acknowledge(string $eventIdHex, string $comment): void
{
$result = $this->client->call(
'ns=0;i=2782', // ConditionType
'ns=0;i=9111', // Acknowledge
[
new \PhpOpcua\Client\Types\Variant(hex2bin($eventIdHex), BuiltinType::ByteString),
['locale' => 'en', 'text' => $comment], // LocalizedText
],
);
if ($result->statusCode !== 0) {
throw new \RuntimeException(sprintf('Ack failed: 0x%X', $result->statusCode));
}
}
}
A Notifier-routed alert
See Integrations · Notifier for
the PlcAlarmNotification class shape.
Severity routing config
framework:
notifier:
channel_policy:
urgent: ['email', 'sms', 'chat/slack'] # sev >= 900
high: ['email', 'chat/slack'] # sev 700-899
medium: ['email'] # sev 400-699
low: ['email']
The Notification's importance field maps to one of these.
Where to read next
- Mercure real-time dashboard — the operator UI for the alarms.
- Production deployment — shipping.