Skip to content

Symfony Notifier — multi-canal SMS, chat, push

TL;DRsymfony/notifier est la couche universelle pour pousser des messages vers n'importe quel canal non-email : SMS (Twilio, Vonage, OVH), chat (Slack, Discord, Telegram, Microsoft Teams, Chatwork), push (Expo, FCM). Vous écrivez une Notification, vous choisissez des canaux selon l'importance (low, medium, high, urgent), et le composant route automatiquement vers les bons transports avec fallback si l'un échoue. C'est l'équivalent du Mailer mais pour tout sauf l'email — et les deux se complètent dans un système de notifications unifié.

🧠 Mental model — ASCII + analogie

Mailer ressemble à La Poste : un protocole, plusieurs transporteurs. Notifier, c'est plutôt une standard téléphonique multi-canaux. Vous appelez la standardiste (le Notifier) en lui disant "envoie ce message à Bob — c'est urgent". Elle regarde comment contacter Bob (préférences) et quels canaux sont dispo pour le niveau "urgent", puis essaie : SMS d'abord → si KO, Slack DM → si KO, push mobile. Vous, vous ne savez rien des transports.

                ┌────────────────────────────────────┐
                │      $notifier->send(               │
                │          $notification,             │
                │          new Recipient(             │
                │              email, phone, ...      │
                │          )                          │
                │      );                             │
                └─────────────────┬──────────────────┘


                  ┌──────────────────────────────┐
                  │      ChannelPolicy            │
                  │  importance → [channels...]   │
                  └────────────┬─────────────────┘

        ┌──────────┬───────────┼───────────┬────────────┐
        ▼          ▼           ▼           ▼            ▼
   ┌────────┐ ┌────────┐ ┌─────────┐ ┌─────────┐ ┌──────────┐
   │  SMS   │ │ Chat   │ │  Email  │ │  Push   │ │ Browser  │
   │(Twilio)│ │(Slack) │ │(Mailer) │ │  (FCM)  │ │   (UX)   │
   └───┬────┘ └───┬────┘ └────┬────┘ └────┬────┘ └────┬─────┘
       │           │           │           │            │
       ▼           ▼           ▼           ▼            ▼
   ╔════════════════════════════════════════════════════════╗
   ║                Destinataires (humains)                  ║
   ╚════════════════════════════════════════════════════════╝

Trois axes orthogonaux :

  1. Transports : qui sait vraiment parler à Twilio / Slack / FCM (configurés par DSN).
  2. Channels : abstraction sur les transports (sms, chat, email, push, browser).
  3. Importance : ce que vous, l'app, exprimez. La policy traduit cette importance en liste de channels à tenter.

🛠️ Code minimal (PHP 8.2+)

Installation

bash
composer require symfony/notifier
# Un transport SMS
composer require symfony/twilio-notifier
# Un transport chat
composer require symfony/slack-notifier
composer require symfony/discord-notifier
composer require symfony/telegram-notifier
# Push mobile (via Expo par ex.)
composer require symfony/expo-notifier

DSN multiples

bash
# .env.local
TWILIO_DSN=twilio://SID:TOKEN@default?from=+33123456789
SLACK_DSN=slack://BOT_TOKEN@default?channel=alerts
DISCORD_DSN=discord://TOKEN@CHANNEL_ID
TELEGRAM_DSN=telegram://TOKEN@default?channel=@learning_hub_ops

Configuration des channels et policies

yaml
# config/packages/notifier.yaml
framework:
    notifier:
        chatter_transports:
            slack:    '%env(SLACK_DSN)%'
            discord:  '%env(DISCORD_DSN)%'
            telegram: '%env(TELEGRAM_DSN)%'
        texter_transports:
            twilio:   '%env(TWILIO_DSN)%'
        channel_policy:
            # importance → channels essayés dans l'ordre
            urgent: ['sms', 'chat/slack', 'email']
            high:   ['chat/slack', 'email']
            medium: ['chat/slack', 'email']
            low:    ['email']
        admin_recipients:
            - { email: '[email protected]', phone: '+33611223344' }

Envoyer une notification simple

php
<?php

declare(strict_types=1);

namespace App\Notification;

use Symfony\Component\Notifier\Notification\Notification;
use Symfony\Component\Notifier\NotifierInterface;
use Symfony\Component\Notifier\Recipient\Recipient;

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

    public function announce(string $version, string $env): void
    {
        $notification = (new Notification("Déploiement {$version} en {$env}"))
            ->content("Le déploiement vient de se terminer avec succès.")
            ->importance(Notification::IMPORTANCE_HIGH);

        $recipient = new Recipient(
            email: '[email protected]',
            phone: '+33611223344',
        );

        $this->notifier->send($notification, $recipient);
    }
}

Vu l'importance high, la channel_policy choisira chat/slack puis fallback email si Slack rate.

Notification personnalisée multi-channel

php
<?php

namespace App\Notification;

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

final class IncidentNotification extends Notification implements
    ChatNotificationInterface,
    SmsNotificationInterface,
    EmailNotificationInterface
{
    public function __construct(
        private readonly string $incidentId,
        private readonly string $service,
        private readonly string $severity,
    ) {
        parent::__construct("Incident {$severity} sur {$service}");
        $this->importance(self::IMPORTANCE_URGENT);
    }

    public function asChatMessage(RecipientInterface $recipient, ?string $transport = null): ?ChatMessage
    {
        return ChatMessage::fromNotification($this)
            ->subject(":rotating_light: Incident *{$this->severity}* sur `{$this->service}`")
        ;
    }

    public function asSmsMessage(SmsRecipientInterface $recipient, ?string $transport = null): ?SmsMessage
    {
        // SMS = court, pas d'emoji surchargé, lien tracking minimal
        return new SmsMessage(
            $recipient->getPhone(),
            "Incident {$this->severity} - {$this->service} - id {$this->incidentId} - https://hub.test/i/{$this->incidentId}"
        );
    }

    public function asEmailMessage(\Symfony\Component\Notifier\Recipient\EmailRecipientInterface $recipient, ?string $transport = null): ?EmailMessage
    {
        // L'enveloppe email peut être un TemplatedEmail Twig.
        // `fromNotification` part du sujet/contenu de la Notification ; on peut
        // ensuite enrichir le Email sous-jacent (htmlTemplate, context...).
        return EmailMessage::fromNotification($this, $recipient);
    }
}

L'avantage : un seul objet domain (IncidentNotification), trois représentations par canal — chaque canal reçoit le format adapté. Le point clé architectural : asXxxMessage() retournant null veut dire « ce canal n'a rien à dire pour ce destinataire » — le Notifier passe alors au canal suivant de la policy. C'est votre point de contrôle pour la suppression conditionnelle (ex. ne pas SMS-er un user dont le numéro n'est pas vérifié, sans toucher la policy globale).

🎯 Patterns courants — 7

1. Asynchrone via Messenger

Comme pour le Mailer, on route les messages du Notifier dans une file :

yaml
framework:
    messenger:
        routing:
            Symfony\Component\Notifier\Message\ChatMessage:  async
            Symfony\Component\Notifier\Message\SmsMessage:   async
            Symfony\Component\Notifier\Message\PushMessage:  async

C'est crucial : un appel Twilio prend 200-800 ms ; vous ne voulez jamais bloquer une requête HTTP là-dessus.

2. Fallback automatique entre transports d'un même canal

yaml
framework:
    notifier:
        chatter_transports:
            primary:    '%env(SLACK_DSN_PRIMARY)%'
            secondary:  '%env(SLACK_DSN_BACKUP)%'   # autre workspace pour redondance
            telegram:   '%env(TELEGRAM_DSN)%'

Avec le pattern chat/primary, on cible un transport précis. Si la policy a chat, n'importe lequel des chatter_transports peut être utilisé (round-robin / failover selon config).

3. Préférences utilisateur (recipient policies)

Tous les utilisateurs ne veulent pas être notifiés sur les mêmes canaux. Pattern : votre entité User implémente une méthode notificationChannels(Notification $n): array consultée avant la policy globale.

php
public function notificationChannels(Notification $notification): array
{
    $byImportance = match ($notification->getImportance()) {
        Notification::IMPORTANCE_URGENT => ['sms', 'email'],
        default => $this->preferences['channels'] ?? ['email'],
    };

    if (!$this->phoneVerified) {
        $byImportance = array_diff($byImportance, ['sms']);
    }

    return $byImportance;
}

Vous pouvez extender la ChannelPolicy ou créer un middleware custom dans Messenger qui filtre.

4. Templates par channel

Pour les SMS et chat, on ne templatise généralement pas avec Twig (texte court, pas de HTML). Mais pour le markdown Slack, on peut utiliser :

php
use Symfony\Component\Notifier\Bridge\Slack\Block\SlackSectionBlock;
use Symfony\Component\Notifier\Bridge\Slack\SlackOptions;

$chatMessage = (new ChatMessage('Build #1234 KO'))
    ->options((new SlackOptions())
        ->iconEmoji(':red_circle:')
        ->block((new SlackSectionBlock())
            ->text("*Build* #1234 *KO*\n_branch: main, commit abc123_")
        )
    );
$chatter->send($chatMessage);

SlackOptions (et DiscordOptions, TelegramOptions…) exposent le format riche natif du provider.

5. Notification "browser" pour UX in-app

Le channel browser n'envoie rien à l'extérieur — il stocke la notification en session pour l'afficher sur la prochaine vue. Utile pour les flashes "votre profil a été mis à jour" si vous voulez les unifier avec le reste.

6. Push mobile (Expo / FCM)

bash
composer require symfony/expo-notifier
# ou
composer require symfony/firebase-notifier
bash
EXPO_DSN=expo://TOKEN@default
FIREBASE_DSN=firebase://USERNAME:PASSWORD@default
php
<?php

use Symfony\Component\Notifier\Bridge\Expo\ExpoOptions;
use Symfony\Component\Notifier\Message\PushMessage;
use Symfony\Component\Notifier\TexterInterface;

final readonly class MobileNotifier
{
    public function __construct(private TexterInterface $texter) {}

    public function notify(string $expoToken, string $title, string $body): void
    {
        $message = new PushMessage(
            subject: $title,
            content: $body,
            options: (new ExpoOptions($expoToken))
                ->priority('high')
                ->channelId('alerts')
                ->badge(1),
        );
        $this->texter->send($message);
    }
}

Côté Recipient, on enrichit avec pushToken :

php
$recipient = new \Symfony\Component\Notifier\Recipient\Recipient(
    email: '[email protected]',
    phone: '+33611223344',
);
// Pour Push, généralement on envoie directement à l'ExpoOptions / FirebaseOptions,
// car le « token push » ne fait pas partie de l'interface Recipient standard.

7. Comparaison Notifier vs Mailer — décider du flow émetteur

CritèreMailer directNotifier
Envoi toujours un email✅ idéal❌ canal email parmi d'autres
Multi-canal (SMS + chat + email)
Fallback transport / canalpartiel (failover SMTP)natif
Importance / policy
Templates Twig riches✅ premier choixOK pour email, basique pour autres
Webhooks / bounces / DKIM✅ pour email (réutilise Mailer en dessous)

Règle simple : si le flow est toujours un email → Mailer. Si c'est "alerter quelqu'un par le moyen le plus approprié" → Notifier.

🔄 Versions — Symfony 5.4 / 6.4 / 7.x

VersionApport principal
5.4 (LTS)API stable du Notifier. Tous les bridges (Twilio, Slack, Discord, Telegram, etc.) déjà disponibles. Pas de PushMessage formel — passe par des bridges externes.
6.0–6.4Stabilisation PushMessage (Expo, OneSignal). Plus de transports : Chatwork, Microsoft Teams, GitHub. Améliorations sur Recipient (SmsRecipientInterface, etc.).
6.4 (LTS)NotifierInterface officiellement réutilisé par d'autres composants (Scheduler peut envoyer des notifications planifiées).
7.0Suppressions deprecations 6.x ; signatures plus strictes (?string $transport paramètres requis).
7.1+Plusieurs bridges supplémentaires (Bluesky, Mastodon). Améliorations transports retries internes.
7.2+Affinement de la ChannelPolicy (lookup recipient.preferred d'abord).

Bridges majeurs à connaître :

  • symfony/twilio-notifier, symfony/vonage-notifier, symfony/ovh-cloud-notifier, symfony/free-mobile-notifier (utile en France).
  • symfony/slack-notifier, symfony/discord-notifier, symfony/telegram-notifier, symfony/microsoft-teams-notifier, symfony/chatwork-notifier.
  • symfony/expo-notifier, symfony/firebase-notifier (FCM).

Construire un système de notifications domain-centric

Au-delà du composant, l'architecture typique consiste à introduire une couche NotificationDispatcher qui :

  1. reçoit un événement domain (UserSignedUp, OrderShipped, PaymentFailed),
  2. construit la Notification adaptée,
  3. récupère les destinataires (depuis la BDD, en respectant leurs préférences),
  4. délègue au NotifierInterface.
php
<?php

namespace App\Notification;

use App\Entity\User;
use App\Repository\UserPreferenceRepository;
use Symfony\Component\Notifier\NotifierInterface;
use Symfony\Component\Notifier\Recipient\Recipient;

final readonly class NotificationDispatcher
{
    public function __construct(
        private NotifierInterface $notifier,
        private UserPreferenceRepository $preferences,
    ) {}

    public function dispatch(DomainNotification $notification, User $user): void
    {
        $prefs = $this->preferences->forUser($user);
        if (!$prefs->wants($notification->category())) {
            return; // opt-out respecté
        }

        $recipient = new Recipient(
            email: $user->getEmail(),
            phone: $user->getPhone() ?? '',
        );

        $this->notifier->send($notification->toSymfonyNotification(), $recipient);
    }
}

DomainNotification est une interface interne — chaque notif (incident, welcome, password-reset) l'implémente et expose category() (pour l'opt-out) et toSymfonyNotification() (pour le composant). Ça vous évite de coupler le code métier à Symfony\Notifier\Notification.

⚠️ Pitfalls — 10

  1. Numéros au format non-E.164. Twilio refuse 06 12 34 56 78. Toujours stocker en +33612345678. Validez via libphonenumber-php.
  2. Quota SMS explose. Un SMS Twilio ≈ 0,05 €. Un workflow buggy = facture à 4 chiffres. Mettez un RateLimiter devant SmsMessage.
  3. Webhooks Slack rate-limited. 1 message/seconde par channel typique. Bufferisez en cas de pic, sinon vos messages se font ban temporairement.
  4. Importance ignorée. Si vous appelez $chatter->send($message) directement (au lieu de $notifier->send($notification, $recipient)), la policy est court-circuitée.
  5. Cycliques entre Mailer et Notifier. Le Notifier peut utiliser le Mailer en dessous (canal email). Évitez de remettre vos EmailMessage du Notifier dans la même file que les SendEmailMessage du Mailer — sinon retries doubles.
  6. Secrets dans DSN en clair dans les logs Messenger. Anonymisez ou utilisez _env(secret)_ côté config et vérifiez que les loggers masquent les DSN.
  7. Locale ignorée. Une notif SMS en anglais à un user FR fait amateur. Passez la locale du Recipient dans le contexte de message.
  8. Slack DM vs channel public. Le DSN slack://...?channel=#ops poste dans #ops. Pour envoyer en DM, il faut le user_id Slack (récupéré via OAuth ou API).
  9. Tests qui posent dans le vrai Slack si DSN dev oublié. Toujours null://null ou un transport MockTransport en environnement test.
  10. Browser channel stocke en session — ne marche pas en API stateless. Pour SPA, exposez une route /api/notifications qui lit la même source.

🏭 Production — ce qu'un staff engineer regarde en premier

Le composant Notifier est facile à câbler ; ce qui casse en prod, ce sont les propriétés systémiques autour. Voici la grille de lecture.

Délivrabilité ≠ envoi réussi

$notifier->send() qui ne lève pas d'exception signifie « le transport a accepté le message », pas « l'humain l'a reçu ». Twilio peut accepter un SMS puis le marquer undelivered 30 s plus tard (carrier filtering, numéro hors-service). Pour les canaux critiques (fraude, 2FA), branchez les webhooks de statut du provider via le composant Webhook (Symfony 6.3+) et réconciliez accepted → sent → delivered/failed dans votre store. Métrique cible : delivery_rate, pas send_rate.

php
// Le SentMessage porte un messageId provider-specific — la clé de réconciliation.
$sent = $texter->send($sms);
$providerId = $sent->getMessageId(); // ex. SMxxxxxxxx chez Twilio
// Persistez (providerId, notificationId, recipientId, 'accepted') puis
// laissez le webhook de statut faire avancer la machine à états.

Idempotence & déduplication

Un worker Messenger qui crash après l'appel Twilio mais avant l'ack rejouera le message → double SMS. Trois lignes de défense :

  1. Idempotency key côté provider quand il en supporte une (FCM collapse_key, certains agrégateurs SMS).
  2. Dedup applicatif : INSERT ... ON CONFLICT DO NOTHING sur (notification_id, channel, recipient) avant l'envoi ; si la ligne existe, on skip.
  3. Outbox pattern : l'événement domain et l'enregistrement « à notifier » sont commités dans la même transaction DB que l'action métier, puis un relay lit l'outbox et envoie. Évite le « j'ai envoyé le SMS mais le rollback DB a annulé la commande ».

Observabilité

SignalQuoi mesurerPourquoi
Métriquenotifications_sent_total{channel,importance,result}détecter une chute de delivery par canal
Métriquelatence event → message accepté (p50/p95/p99)SLA des alertes urgentes (fraude < 8 s)
Métriqueprofondeur de queue Messenger par transportback-pressure / worker starvation
LognotificationId, channel, transport, providerMessageIdcorréler webhook de statut ↔ envoi
Tracespan par canal tenté dans la policyvoir quel fallback a effectivement servi

Le Notifier dispatche des events (NotificationEvents, plus les events Messenger SendMessageToTransportsEvent / WorkerMessageFailedEvent). Abonnez un subscriber qui pousse vos compteurs — ne loggez pas le contenu (PII : montant, numéro, lien de validation).

Coût & rate limiting — le réflexe à câbler dès le jour 1

Le SMS et le push à fort volume sont des lignes de facturation directes et des vecteurs d'abus. Posez un RateLimiter (token bucket) par destinataire ET global, avant l'envoi, idéalement dans un middleware Messenger pour que le throttle survive aux retries.

php
use Symfony\Component\RateLimiter\RateLimiterFactory;

final readonly class ThrottledSmsGate
{
    public function __construct(private RateLimiterFactory $smsPerUserLimiter) {}

    public function allow(string $recipientId): bool
    {
        // 6 SMS / jour / utilisateur, refus silencieux au-delà.
        return $this->smsPerUserLimiter->create($recipientId)->consume(1)->isAccepted();
    }
}

Un budget circuit-breaker global (« coupe tous les SMS si > N €/h ») évite la facture à 4 chiffres d'un bug de boucle.

Sécurité & conformité

  • PII minimale dans le payload : un SMS de fraude ne contient ni nom, ni solde, ni numéro de carte. Lien opaque + token court-vivant.
  • RGPD / opt-out : la décision « peut-on contacter cet humain sur ce canal ? » se prend avant la policy technique, à partir d'un store de préférences, et doit être auditée.
  • Secrets : les DSN (token Twilio/Slack) sont des secrets — secrets:set, jamais dans .env committé, et vérifiez que vos loggers Messenger masquent le DSN sérialisé.
  • Anti-spoofing chat : un message Slack « Build KO, cliquez ici » est un super vecteur de phishing interne. Préférez Block Kit avec actions signées plutôt que des liens bruts.

🧪 Testing — phpunit + KernelTestCase

Configuration test

yaml
# config/packages/test/notifier.yaml
framework:
    notifier:
        chatter_transports:
            slack: 'null://null'
        texter_transports:
            twilio: 'null://null'

Assertions

php
<?php

namespace App\Tests\Notification;

use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Notifier\Notification\Notification;
use Symfony\Component\Notifier\NotifierInterface;
use Symfony\Component\Notifier\Recipient\Recipient;
use Symfony\Component\Notifier\Test\Constraint\NotificationSubjectMatches;

final class DeploymentAnnouncerTest extends KernelTestCase
{
    public function testItSendsHighImportanceNotification(): void
    {
        self::bootKernel();
        $notifier = static::getContainer()->get(NotifierInterface::class);

        $notification = (new Notification('Déploiement v1.2 en prod'))
            ->importance(Notification::IMPORTANCE_HIGH);
        $recipient = new Recipient('[email protected]', '+33611223344');

        $notifier->send($notification, $recipient);

        // Le bundle test ajoute des assertions sur les transports null
        self::assertNotificationCount(1);
        self::assertNotificationSubjectMatches('Déploiement v1.2');
    }
}

Mock du transport

php
use Symfony\Component\Notifier\Bridge\Slack\SlackTransport;
use Symfony\Component\Notifier\Message\SentMessage;

$transport = $this->createMock(SlackTransport::class);
$transport->expects(self::once())
    ->method('send')
    ->willReturn(new SentMessage($message, 'slack'));

Test d'intégration de la ChannelPolicy

php
<?php

namespace App\Tests\Notification;

use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Notifier\Channel\ChannelPolicy;
use Symfony\Component\Notifier\Notification\Notification;

final class ChannelPolicyTest extends KernelTestCase
{
    public function testUrgentRoutesViaSmsFirst(): void
    {
        self::bootKernel();
        /** @var ChannelPolicy $policy */
        $policy = static::getContainer()->get('notifier.channel_policy');

        $channels = $policy->getChannels(Notification::IMPORTANCE_URGENT);
        self::assertSame(['sms', 'chat/slack', 'email'], $channels);
    }

    public function testLowImportanceUsesEmailOnly(): void
    {
        self::bootKernel();
        $policy = static::getContainer()->get('notifier.channel_policy');

        self::assertSame(['email'], $policy->getChannels(Notification::IMPORTANCE_LOW));
    }
}

Patterns de test avec sync:// transport

Comme pour le Mailer, mettre Messenger en sync:// dans config/packages/test/messenger.yaml permet de tester toute la chaîne ($notifier->send() → message → handler → mock transport) en un seul appel.

yaml
# config/packages/test/messenger.yaml
framework:
    messenger:
        transports:
            async: 'sync://'

🎬 Cas d'usage concrets

Scénario 1 — SMS alerte fraude banque (Hello Bank, Boursorama, Younited)

Une banque détecte un paiement suspect (montant > seuil, géolocalisation inhabituelle, marchand black-listé). Le moteur antifraude pousse un événement FraudSuspectedEvent qui notifie le client par 3 canaux séquencés : SMS d'abord (canal sms via Orange API ou OVH SMS, livraison <5s en France), email en backup si SMS bounce (numéro invalide, hors France), push mobile si l'app est installée. La ChatterInterface n'est pas utilisée — c'est NotifierInterface qui orchestre via les Notifier::policy('fraud_alert') qui définit urgent → SMS + push, high → SMS, low → email. Le SMS contient un lien court (https://b.example/v/abc123) vers une page de validation 3DS. Les TwoFactorAuth (Numéro carte + OTP SMS) protègent les actions sensibles. Le contenu SMS est strictement limité (sans nom client, sans solde, juste "Paiement de 230€ chez X. Vous ? Cliquez pour bloquer" + lien). Tous les SMS partent sur Messenger transport prioritaire (queue notif_urgent avec 12 workers dédiés, latence cible < 8s du fraud event au SMS reçu).

Scénario 2 — Notification Slack équipe juridique (Septeo, Cellence)

Un cabinet d'avocats utilise Slack en interne pour la coordination. Notifier centralise les notifications côté équipe : nouvelle audience programmée par le greffe (RPVA) → message dans #audiences-urgentes avec date, juridiction, dossier, avocat assigné ; nouveau document signé eIDAS par le client → message dans le canal #dossiers-{matter_id} (canal par dossier) ; alerte délai de prescription < 30 jours → mention @here dans #deadlines. Le ChatNotificationInterface route vers la SlackTransport configurée avec un Bot Token. Les messages utilisent les SlackBlockBuilder pour formatter (boutons "Marquer urgent", "Assigner à"). Les workflows Slack appellent en retour des webhooks Symfony (Webhook component) qui mettent à jour le DMS. Notifier sert aussi le canal email pour les avocats en déplacement (Slack pas activé sur mobile). En cas de panne Slack (rare), un fallback Microsoft Teams est configuré comme transport secondaire avec failover().

Scénario 3 — Push e-commerce livraison (Veepee, La Redoute, Cdiscount)

Une boutique en ligne notifie chaque étape de la commande : confirmation, préparation, expédition (avec tracking), livraison imminente, livraison effectuée, demande d'avis. Les canaux choisis selon les préférences client : push mobile via Expo / OneSignal (expo+https://... transport custom), SMS pour les colis fragiles ou >300€ via OVH, email pour les non-urgents, webhook personnel pour les power users (zapier-like). La gestion Recipient typée combine email + téléphone + device_token. La NotificationInterface est étendue : OrderShippedNotification implements ChatNotificationInterface, SmsNotificationInterface, PushNotificationInterface avec des renderers spécifiques par canal (template Twig différent par canal). Les notifications respectent l'opt-in RGPD (table customer_communication_preferences). Anti-flood : un même client ne reçoit pas plus de 6 notifications/jour (rate limiter notification_throttle). Mesure : taux d'ouverture push 38% vs email 12%, donc l'équipe pousse en priorité sur push.

🛠️ Exemple end-to-end

Cas : alerte fraude banque multi-canal (SMS priorité 1 + push backup + email) avec fallback automatique.

php
<?php
// src/Notification/FraudAlertNotification.php
declare(strict_types=1);

namespace App\Notification;

use Symfony\Component\Notifier\Message\PushMessage;
use Symfony\Component\Notifier\Message\SmsMessage;
use Symfony\Component\Notifier\Notification\EmailNotificationInterface;
use Symfony\Component\Notifier\Notification\Notification;
use Symfony\Component\Notifier\Notification\PushNotificationInterface;
use Symfony\Component\Notifier\Notification\SmsNotificationInterface;
use Symfony\Component\Notifier\Recipient\EmailRecipientInterface;
use Symfony\Component\Notifier\Recipient\Recipient;
use Symfony\Component\Notifier\Recipient\RecipientInterface;
use Symfony\Component\Notifier\Recipient\SmsRecipientInterface;

final class FraudAlertNotification extends Notification implements
    SmsNotificationInterface,
    PushNotificationInterface,
    EmailNotificationInterface
{
    public function __construct(
        private readonly string $merchantName,
        private readonly string $amount,
        private readonly string $confirmationUrl,
    ) {
        parent::__construct('Alerte paiement suspect');
        $this->importance(Notification::IMPORTANCE_URGENT);
        $this->channels(['sms', 'push', 'email']);
    }

    public function asSmsMessage(SmsRecipientInterface $recipient, ?string $transport = null): ?SmsMessage
    {
        $body = sprintf(
            'BANK: paiement de %s EUR chez %s. Est-ce vous ? Sinon bloquez: %s',
            $this->amount,
            $this->merchantName,
            $this->confirmationUrl,
        );
        return new SmsMessage($recipient->getPhone(), $body);
    }

    public function asPushMessage(RecipientInterface $recipient, ?string $transport = null): ?PushMessage
    {
        return new PushMessage(
            'Paiement à vérifier',
            sprintf('%s EUR chez %s', $this->amount, $this->merchantName),
        );
    }

    public function asEmailMessage(EmailRecipientInterface $recipient, ?string $transport = null): ?\Symfony\Component\Notifier\Message\EmailMessage
    {
        return \Symfony\Component\Notifier\Message\EmailMessage::fromNotification(
            $this->subject($this->getSubject())->content(sprintf(
                'Nous avons détecté un paiement de %s EUR chez %s. Pour le valider ou le contester : %s',
                $this->amount,
                $this->merchantName,
                $this->confirmationUrl,
            )),
            new Recipient($recipient->getEmail()),
        );
    }
}
php
<?php
// src/MessageHandler/FraudSuspectedHandler.php
declare(strict_types=1);

namespace App\MessageHandler;

use App\Message\FraudSuspected;
use App\Notification\FraudAlertNotification;
use App\Repository\CustomerRepository;
use App\Service\ShortLinkGenerator;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Notifier\NotifierInterface;
use Symfony\Component\Notifier\Recipient\Recipient;

#[AsMessageHandler]
final readonly class FraudSuspectedHandler
{
    public function __construct(
        private NotifierInterface $notifier,
        private CustomerRepository $customers,
        private ShortLinkGenerator $linkGenerator,
    ) {}

    public function __invoke(FraudSuspected $message): void
    {
        $customer = $this->customers->get($message->customerId);
        $url = $this->linkGenerator->generate('fraud_confirm', ['token' => $message->challengeToken]);

        $notification = new FraudAlertNotification(
            merchantName: $message->merchantName,
            amount: $message->amount,
            confirmationUrl: $url,
        );

        $this->notifier->send(
            $notification,
            new Recipient(
                email: $customer->email,
                phone: $customer->phone,
            ),
        );
    }
}
yaml
# config/packages/notifier.yaml
framework:
    notifier:
        chatter_transports: {}
        texter_transports:
            ovh: '%env(OVH_SMS_DSN)%'
            ovh_backup: '%env(OVH_SMS_BACKUP_DSN)%'
            expo: '%env(EXPO_PUSH_DSN)%'
        channel_policy:
            urgent: ['sms', 'push', 'email']
            high: ['sms', 'email']
            medium: ['email']
            low: ['email']
        admin_recipients:
            - { email: '[email protected]', phone: '+33611223344' }
yaml
# config/packages/messenger.yaml
framework:
    messenger:
        transports:
            notif_urgent:
                dsn: '%env(MESSENGER_REDIS_URGENT_DSN)%'
                retry_strategy:
                    max_retries: 3
                    delay: 500
                    multiplier: 2
        routing:
            App\Message\FraudSuspected: notif_urgent
            Symfony\Component\Notifier\Message\ChatMessage: notif_urgent
            Symfony\Component\Notifier\Message\SmsMessage: notif_urgent

Couverture : notification multi-canal typée + handler async + policy par importance + transports SMS principal/backup. Le client reçoit l'alerte en <8s du déclenchement antifraude.


🏋️ Exercices

Progression : du câblage → au grade production → casser puis réparer. Faites-les dans l'ordre, chacun s'appuie sur le précédent.

1. Notification multi-canal typée (échauffement)

Objectif : créer une WelcomeNotification qui rend un texte court en SMS, un Block Kit en Slack, et un TemplatedEmail en email — un seul objet domain, trois représentations.

Indice/Solution : extends Notification implements SmsNotificationInterface, ChatNotificationInterface, EmailNotificationInterface. Dans asChatMessage, ChatMessage::fromNotification($this)->subject(...)->options(new SlackOptions()...). Dans asEmailMessage, EmailMessage::fromNotification($this, $recipient) puis ->getMessage() pour caster en TemplatedEmail et poser htmlTemplate(). Testez avec null://null + self::assertNotificationCount(1).

2. Préférences utilisateur > policy globale

Objectif : une entité User implements RecipientInterface qui expose getChannels(Notification $n): array, consultée avant la channel_policy. Un user opt-out SMS ne doit jamais recevoir de SMS, même en importance urgent.

Indice/Solution : implémentez la suppression dans asSmsMessage() (return null si !$user->smsEnabled()) et filtrez en amont via les channels du recipient pour ne pas « brûler » un fallback. Test : un user SMS-off + notif urgente ⇒ assertNotificationCount sur SMS = 0, sur email/push > 0.

3. Throttle + circuit breaker de coût (production-grade)

Objectif : un middleware Messenger qui (a) limite à 6 SMS/jour/utilisateur, (b) coupe tous les SMS si le coût horaire estimé dépasse un budget. Refus silencieux + métrique incrémentée, jamais d'exception qui bloque les autres canaux.

Indice/Solution : RateLimiterFactory par user (sliding window) + un compteur global Redis INCRBYFLOAT cost:hour avec TTL 3600. Le middleware intercepte les SmsMessage ; au-delà du budget, return $stack->next() sans réémettre. Émettez sms_dropped_total{reason=budget|quota}. Piège : le throttle doit vivre avant retry, sinon un message rejoué consomme un nouveau token.

4. Délivrabilité réelle via webhooks de statut

Objectif : réconcilier accepted → delivered/failed à partir des webhooks provider. Une notif marquée accepted mais jamais delivered après 60 s déclenche un fallback automatique vers le canal suivant.

Indice/Solution : persistez (providerMessageId, notificationId, channel, state) à l'envoi (SentMessage::getMessageId()). Route Webhook Symfony qui parse le callback Twilio/OVH et avance la machine à états. Un message scheduler (Scheduler component) scanne les accepted périmés et republie la notif en excluant le canal défaillant. Test : simulez un webhook undelivered et assertez le re-routage.

5. Outbox pattern — atomicité métier/notification (architecture)

Objectif : garantir « la commande est créée si et seulement si la notif d'expédition est programmée ». Pas de SMS envoyé sur une transaction rollback ; pas de commande sans notif.

Indice/Solution : dans la même transaction Doctrine que l'Order, insérez une ligne notification_outbox (status pending). Un relay (worker ou Scheduler) lit l'outbox, appelle $notifier->send(), marque sent — avec dedup (notification_id) pour l'idempotence du relay. Cassez-le : tuez le worker entre send() et le UPDATE status=sent, prouvez qu'au redémarrage il n'y a pas de double envoi grâce au flag d'idempotence côté provider ou à un ON CONFLICT.

6. Break-then-fix : la tempête de fallback

Objectif : reproduire un incident où Slack est down, la policy bascule en email, le SMTP est lent (5 s/mail), les workers s'empilent, et la queue notif_urgent prend 4 min de retard — les SMS de fraude arrivent trop tard. Diagnostiquez, puis corrigez.

Indice/Solution : la cause racine est le partage de queue entre canaux de latence très différente. Fix : queues dédiées par classe de message (SmsMessagenotif_urgent à 12 workers ; EmailMessagenotif_bulk), failover() Slack→Teams au lieu de retomber sur l'email lent, et un timeout court sur le transport SMTP. Mesurez la latence p99 event→delivered avant/après pour prouver le fix.

🎤 En entretien

Q : Quelle est la différence entre Notifier, Chatter et Texter ? Quand injecter lequel ? R : NotifierInterface est la couche haute — vous passez une Notification + un Recipient, et la ChannelPolicy choisit les canaux selon l'importance avec fallback. ChatterInterface et TexterInterface sont des couches basses : vous envoyez un ChatMessage/SmsMessage/PushMessage à un transport précis, sans policy ni importance. Injectez Notifier pour « alerter le bon humain par le bon moyen » ; injectez Chatter/Texter pour un envoi déterministe vers un canal connu (ex. poster un build dans #ci).

Q : Comment garantir qu'un événement métier critique ne génère ni double notification ni notification fantôme ? R : Outbox pattern — la ligne « à notifier » est commitée dans la même transaction DB que l'action métier (pas de fantôme sur rollback). Côté envoi, idempotence : dedup ON CONFLICT (notification_id) ou idempotency key provider, pour qu'un retry Messenger après crash ne double pas. Le « send réussi » n'est jamais l'autorité — c'est la réconciliation via webhooks de statut qui l'est.

Q : $notifier->send() n'a pas levé d'exception. Le client a-t-il reçu le SMS ? R : Non, rien ne le garantit. send() confirme que le transport a accepté le message (état accepted), pas la livraison. Le carrier peut filtrer, le numéro être hors service, le provider marquer undelivered après coup. Pour les canaux critiques, il faut brancher les webhooks de statut et piloter une machine à états accepted → sent → delivered/failed, avec fallback sur le canal suivant si pas de delivered dans le SLA.

Q : Vous avez 500k push à envoyer pour une promo. Notifier est-il le bon outil ? R : Non. Le bridge Symfony envoie unitairement (un message = un appel transport), il est pensé pour le transactionnel à fallback, pas pour le batch massif. Pour 500k push, on utilise le SDK natif FCM/APNS avec batching et topics, ou un service dédié (OneSignal). Notifier reste pertinent pour les notifications transactionnelles ciblées (1 → N petits) où l'importance et le fallback comptent.


🔁 Quand utiliser / éviter

Utilisez Notifier quand :

  • Vous avez plusieurs canaux possibles pour un même message.
  • Vous voulez prioriser par importance et avoir un fallback automatique.
  • Vous gérez des alertes ops/incidents (Slack + SMS de garde + email pager).
  • Vous unifiez les notifications dans un système (badges in-app, push mobile, etc.).

Évitez si :

  • 100% email : passez direct par le Mailer (moins d'indirection).
  • Newsletters / marketing : pas le bon outil, le Notifier est transactionnel.
  • Push à très haut volume : utilisez le SDK natif FCM/APNS pour batcher (le bridge Symfony est unitaire).
  • Voix / appels téléphoniques : pas de canal "voice" natif ; il faut faire du Twilio direct.

🔗 Liens

Bibliothèque tech perso — Achref