symfony-opcua · master
Docs · Integrations

Notifier

Symfony Notifier for alerts from OPC UA events — Slack, email, SMS, Discord. Channel policy, severity routing, on-call rotation.

Symfony Notifier turns plant-floor events into messages across channels — Slack, mail, SMS, Discord, MS Teams, database. The OPC UA bundle's events make this a small bridge.

Install

bash terminal
composer require symfony/notifier

# Channel-specific transports
composer require symfony/slack-notifier
composer require symfony/twilio-notifier
composer require symfony/discord-notifier

Configure transports

text config/packages/notifier.yaml
framework:
    notifier:
        chatter_transports:
            slack:    '%env(SLACK_DSN)%'
            discord:  '%env(DISCORD_DSN)%'
        texter_transports:
            twilio:   '%env(TWILIO_DSN)%'
        channel_policy:
            # mapping importance → channels
            urgent: ['email', 'sms', 'chat/slack']
            high:   ['email', 'chat/slack']
            medium: ['email']
            low:    ['email']
        admin_recipients:
            - { email: [email protected], phone: '+15551234567' }

.env:

bash DSNs
SLACK_DSN=slack://TOKEN@default?channel=ops-alerts
DISCORD_DSN=discord://TOKEN@CHANNEL_ID
TWILIO_DSN=twilio://ACCOUNT_SID:AUTH_TOKEN@default?from=PHONE

A Notification class

php src/Notification/PlcAlarmNotification.php
namespace App\Notification;

use Symfony\Component\Notifier\Message\ChatMessage;
use Symfony\Component\Notifier\Message\SmsMessage;
use Symfony\Component\Notifier\Notification\ChatNotificationInterface;
use Symfony\Component\Notifier\Notification\Notification;
use Symfony\Component\Notifier\Notification\SmsNotificationInterface;
use Symfony\Component\Notifier\Recipient\RecipientInterface;

final class PlcAlarmNotification extends Notification implements
    ChatNotificationInterface,
    SmsNotificationInterface
{
    public function __construct(
        public readonly string $source,
        public readonly int $severity,
        public readonly string $message,
    ) {
        parent::__construct("[PLC alarm] $source");
        $this->content("$message (severity $severity)");
        $this->importance($this->mapImportance());
    }

    private function mapImportance(): string
    {
        return match (true) {
            $this->severity >= 900 => Notification::IMPORTANCE_URGENT,
            $this->severity >= 700 => Notification::IMPORTANCE_HIGH,
            $this->severity >= 400 => Notification::IMPORTANCE_MEDIUM,
            default                  => Notification::IMPORTANCE_LOW,
        };
    }

    public function asChatMessage(RecipientInterface $r, ?string $transport = null): ?ChatMessage
    {
        return new ChatMessage(
            sprintf("⚠️ PLC alarm — %s (severity %d): %s", $this->source, $this->severity, $this->message),
        );
    }

    public function asSmsMessage(RecipientInterface $r, ?string $transport = null): ?SmsMessage
    {
        return new SmsMessage(
            $r->getPhone(),
            sprintf("PLC sev=%d %s: %s", $this->severity, $this->source, $this->message),
        );
    }
}

The event listener

php src/EventListener/RouteAlarmToOps.php
namespace App\EventListener;

use App\Notification\PlcAlarmNotification;
use PhpOpcua\Client\Event\AlarmActivated;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\Notifier\NotifierInterface;
use Symfony\Component\Notifier\Recipient\Recipient;

final class RouteAlarmToOps
{
    public function __construct(private NotifierInterface $notifier) {}

    #[AsEventListener]
    public function __invoke(AlarmActivated $event): void
    {
        if ($event->severity < 400) {
            return;
        }

        $this->notifier->send(
            new PlcAlarmNotification(
                source:   $event->sourceName,
                severity: $event->severity,
                message:  $event->message,
            ),
            new Recipient('[email protected]', '+15551234567'),
        );
    }
}

Notifier::send() consults the channel_policy and routes based on importance. AlarmActivated carries $sourceName / $severity / $message directly — they are non-null public fields.

Per-recipient routing

For routing different alerts to different teams:

php per-team routing
$this->notifier->send($notif, ...$this->recipientsForSource($event->sourceName));

// ...

private function recipientsForSource(string $source): array
{
    return match (true) {
        str_starts_with($source, 'Line-A') => [
            new Recipient('[email protected]', '+15551111111'),
        ],
        str_starts_with($source, 'Line-B') => [
            new Recipient('[email protected]', '+15552222222'),
        ],
        default => [
            new Recipient('[email protected]', '+15551234567'),
        ],
    };
}

For dynamic team rosters, look up in a Team Doctrine entity.

On-call rotation

For rotating on-call:

php dynamic on-call
use App\Repository\OncallRosterRepository;

#[AsEventListener]
public function __invoke(AlarmActivated $event): void
{
    if ($event->severity < 800) return;

    $oncall = $this->oncallRepo->current();
    foreach ($oncall as $person) {
        $this->notifier->send(
            new PlcAlarmNotification($event->sourceName, $event->severity, $event->message),
            new Recipient($person->getEmail(), $person->getPhone()),
        );
    }
}

OncallRosterRepository::current() is your domain — a query like WHERE start_at <= NOW() AND end_at >= NOW().

Connection-failure alerts

php connection alerts
namespace App\EventListener;

use App\Notification\PlcConnectionLostNotification;
use PhpOpcua\Client\Event\ConnectionFailed;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\Notifier\NotifierInterface;
use Symfony\Component\Notifier\Recipient\Recipient;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;

final class NotifyConnectionLost
{
    public function __construct(
        private NotifierInterface $notifier,
        private CacheInterface $cache,
    ) {}

    #[AsEventListener]
    public function __invoke(ConnectionFailed $event): void
    {
        // Throttle — one per endpoint per 10 minutes
        $key = 'conn-fail.' . sha1($event->endpointUrl);
        $blocked = $this->cache->get($key, function (ItemInterface $i) {
            $i->expiresAfter(600);
            return false;
        });
        if ($blocked) return;

        $this->cache->delete($key);
        $this->cache->get($key, function (ItemInterface $i) {
            $i->expiresAfter(600);
            return true;
        });

        $this->notifier->send(
            new PlcConnectionLostNotification(
                endpoint: $event->endpointUrl,
                reason:   $event->exception::class,
                message:  $event->exception->getMessage(),
            ),
            new Recipient('[email protected]', '+15551234567'),
        );
    }
}

Throttling is essential — flapping connections fire many events per minute.

All-clear notification

There is no dedicated "reconnected" event in opcua-client. After a recovery attempt (ClientReconnecting fires when it starts), the next ClientConnected is the all-clear signal. Use it to clear a previously-set "lost" flag:

php reconnect notice
namespace App\EventListener;

use App\Notification\PlcReconnectedNotification;
use PhpOpcua\Client\Event\ClientConnected;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\Notifier\NotifierInterface;
use Symfony\Component\Notifier\Recipient\Recipient;
use Symfony\Contracts\Cache\CacheInterface;

final class NotifyReconnected
{
    public function __construct(
        private NotifierInterface $notifier,
        private CacheInterface $cache,
    ) {}

    #[AsEventListener]
    public function __invoke(ClientConnected $event): void
    {
        $key = 'conn-fail.' . sha1($event->endpointUrl);
        $hasOpenAlert = $this->cache->getItem($key)->isHit();

        if ($hasOpenAlert) {
            $this->cache->delete($key);
            $this->notifier->send(
                new PlcReconnectedNotification($event->endpointUrl),
                new Recipient('[email protected]'),
            );
        }
    }
}

Only fires "all clear" if we previously fired "lost".

Database channel

For an audit trail of all notifications:

text add db channel
framework:
    notifier:
        chatter_transports:
            slack: '%env(SLACK_DSN)%'
        # ...
        channel_policy:
            urgent: ['email', 'sms', 'chat/slack', 'database']

You'd add a database:// transport via a custom factory — see the Symfony Notifier docs.

You've finished Integrations. Next: Reference · OpcuaManager API.