Step 25: Notifying by all Means

Notifying by all Means

As comments are moderated, they probably don’t understand why their comments are not published instantly. They might even re-submit them thinking there was some technical problems. Giving them feedback after posting a comment would be great.

Also, we should probably ping them when their comment has been published. We ask for their email, so we’d better use it.

There are many ways to notify users. Email is the first medium that you might think about, but notifications in the web application is another one. We could even think about sending SMS messages, posting a message on Slack or Telegram. There are many options.

The Symfony Notifier Component implements many notification strategies:

As a first step, let’s notify the users that comments are moderated directly in the browser after their submission:

patch_file

  1. +++ b/src/Controller/ConferenceController.php
  2. @@ -14,6 +14,8 @@ use Symfony\Component\HttpFoundation\File\Exception\FileException;
  3. use Symfony\Component\HttpFoundation\Request;
  4. use Symfony\Component\HttpFoundation\Response;
  5. use Symfony\Component\Messenger\MessageBusInterface;
  6. +use Symfony\Component\Notifier\Notification\Notification;
  7. +use Symfony\Component\Notifier\NotifierInterface;
  8. use Symfony\Component\Routing\Annotation\Route;
  9. use Twig\Environment;
  10. @@ -53,7 +55,7 @@ class ConferenceController extends AbstractController
  11. }
  12. #[Route('/conference/{slug}', name: 'conference')]
  13. - public function show(Request $request, Conference $conference, CommentRepository $commentRepository, string $photoDir): Response
  14. + public function show(Request $request, Conference $conference, CommentRepository $commentRepository, NotifierInterface $notifier, string $photoDir): Response
  15. {
  16. $comment = new Comment();
  17. $form = $this->createForm(CommentFormType::class, $comment);
  18. @@ -82,9 +84,15 @@ class ConferenceController extends AbstractController
  19. $this->bus->dispatch(new CommentMessage($comment->getId(), $context));
  20. + $notifier->send(new Notification('Thank you for the feedback; your comment will be posted after moderation.', ['browser']));
  21. +
  22. return $this->redirectToRoute('conference', ['slug' => $conference->getSlug()]);
  23. }
  24. + if ($form->isSubmitted()) {
  25. + $notifier->send(new Notification('Can you check your submission? There are some problems with it.', ['browser']));
  26. + }
  27. +
  28. $offset = max(0, $request->query->getInt('offset', 0));
  29. $paginator = $commentRepository->getCommentPaginator($conference, $offset);

The notifier sends a notification to recipients via a channel.

A notification has a subject, an optional content, and an importance.

A notification is sent on one or many channels depending on its importance. You can send urgent notifications by SMS and regular ones by email for instance.

For browser notifications, we don’t have recipients.

The browser notification uses flash messages via the notification section. We need to display them by updating the conference template:

patch_file

  1. --- a/templates/conference/show.html.twig
  2. +++ b/templates/conference/show.html.twig
  3. @@ -3,6 +3,13 @@
  4. {% block title %}Conference Guestbook - {{ conference }}{% endblock %}
  5. {% block body %}
  6. + {% for message in app.flashes('notification') %}
  7. + <div class="alert alert-info alert-dismissible fade show">
  8. + {{ message }}
  9. + <button type="button" class="close" data-dismiss="alert" aria-label="Close"><span aria-hidden="true">&times;</span></button>
  10. + </div>
  11. + {% endfor %}
  12. +
  13. <h2 class="mb-5">
  14. {{ conference }} Conference
  15. </h2>

The users will now be notified that their submission is moderated:

As an added bonus, we have a nice notification at the top of the website if there is a form error:

Step 25: Notifying by all Means - 图2

Tip

Flash messages use the HTTP session system as a storage medium. The main consequence is that the HTTP cache is disabled as the session system must be started to check for messages.

This is the reason why we have added the flash messages snippet in the show.html.twig template and not in the base one as we would have lost HTTP cache for the homepage.

Instead of sending an email via MailerInterface to notify the admin that a comment has just been posted, switch to use the Notifier component in the message handler:

  1. --- a/src/MessageHandler/CommentMessageHandler.php
  2. +++ b/src/MessageHandler/CommentMessageHandler.php
  3. @@ -4,14 +4,14 @@ namespace App\MessageHandler;
  4. use App\ImageOptimizer;
  5. use App\Message\CommentMessage;
  6. +use App\Notification\CommentReviewNotification;
  7. use App\Repository\CommentRepository;
  8. use App\SpamChecker;
  9. use Doctrine\ORM\EntityManagerInterface;
  10. use Psr\Log\LoggerInterface;
  11. -use Symfony\Bridge\Twig\Mime\NotificationEmail;
  12. -use Symfony\Component\Mailer\MailerInterface;
  13. use Symfony\Component\Messenger\Handler\MessageHandlerInterface;
  14. use Symfony\Component\Messenger\MessageBusInterface;
  15. +use Symfony\Component\Notifier\NotifierInterface;
  16. use Symfony\Component\Workflow\WorkflowInterface;
  17. class CommentMessageHandler implements MessageHandlerInterface
  18. @@ -21,22 +21,20 @@ class CommentMessageHandler implements MessageHandlerInterface
  19. private $commentRepository;
  20. private $bus;
  21. private $workflow;
  22. - private $mailer;
  23. + private $notifier;
  24. private $imageOptimizer;
  25. - private $adminEmail;
  26. private $photoDir;
  27. private $logger;
  28. - public function __construct(EntityManagerInterface $entityManager, SpamChecker $spamChecker, CommentRepository $commentRepository, MessageBusInterface $bus, WorkflowInterface $commentStateMachine, MailerInterface $mailer, ImageOptimizer $imageOptimizer, string $adminEmail, string $photoDir, LoggerInterface $logger = null)
  29. + public function __construct(EntityManagerInterface $entityManager, SpamChecker $spamChecker, CommentRepository $commentRepository, MessageBusInterface $bus, WorkflowInterface $commentStateMachine, NotifierInterface $notifier, ImageOptimizer $imageOptimizer, string $photoDir, LoggerInterface $logger = null)
  30. {
  31. $this->entityManager = $entityManager;
  32. $this->spamChecker = $spamChecker;
  33. $this->commentRepository = $commentRepository;
  34. $this->bus = $bus;
  35. $this->workflow = $commentStateMachine;
  36. - $this->mailer = $mailer;
  37. + $this->notifier = $notifier;
  38. $this->imageOptimizer = $imageOptimizer;
  39. - $this->adminEmail = $adminEmail;
  40. $this->photoDir = $photoDir;
  41. }
  42. @@ -62,13 +60,7 @@ class CommentMessageHandler implements MessageHandlerInterface
  43. $this->bus->dispatch($message);
  44. } elseif ($this->workflow->can($comment, 'publish') || $this->workflow->can($comment, 'publish_ham')) {
  45. - $this->mailer->send((new NotificationEmail())
  46. - ->subject('New comment posted')
  47. - ->htmlTemplate('emails/comment_notification.html.twig')
  48. - ->from($this->adminEmail)
  49. - ->to($this->adminEmail)
  50. - );
  51. + $this->notifier->send(new CommentReviewNotification($comment), ...$this->notifier->getAdminRecipients());
  52. } elseif ($this->workflow->can($comment, 'optimize')) {
  53. if ($comment->getPhotoFilename()) {
  54. $this->imageOptimizer->resize($this->photoDir.'/'.$comment->getPhotoFilename());

The getAdminRecipients() method returns the admin recipients as configured in the notifier configuration; update it now to add your own email address:

patch_file

  1. --- a/config/packages/notifier.yaml
  2. +++ b/config/packages/notifier.yaml
  3. @@ -13,4 +13,4 @@ framework:
  4. medium: ['email']
  5. low: ['email']
  6. admin_recipients:
  7. - - { email: }
  8. + - { email: "%env(string:default:default_admin_email:ADMIN_EMAIL)%" }

Now, create the CommentReviewNotification class:

src/Notification/CommentReviewNotification.php

  1. namespace App\Notification;
  2. use App\Entity\Comment;
  3. use Symfony\Component\Notifier\Message\EmailMessage;
  4. use Symfony\Component\Notifier\Notification\EmailNotificationInterface;
  5. use Symfony\Component\Notifier\Notification\Notification;
  6. use Symfony\Component\Notifier\Recipient\EmailRecipientInterface;
  7. class CommentReviewNotification extends Notification implements EmailNotificationInterface
  8. {
  9. private $comment;
  10. public function __construct(Comment $comment)
  11. {
  12. $this->comment = $comment;
  13. parent::__construct('New comment posted');
  14. }
  15. public function asEmailMessage(EmailRecipientInterface $recipient, string $transport = null): ?EmailMessage
  16. {
  17. $message = EmailMessage::fromNotification($this, $recipient, $transport);
  18. $message->getMessage()
  19. ->htmlTemplate('emails/comment_notification.html.twig')
  20. ->context(['comment' => $this->comment])
  21. ;
  22. return $message;
  23. }
  24. }

The asEmailMessage() method from EmailNotificationInterface is optional, but it allows to customize the email.

One benefit of using the Notifier instead of the mailer directly to send emails is that it decouples the notification from the “channel” used for it. As you can see, nothing explicitly says that the notification should be sent by email.

Instead, the channel is configured in config/packages/notifier.yaml depending on the importance of the notification (low by default):

config/packages/notifier.yaml

We have talked about the browser and the email channels. Let’s see some fancier ones.

Let’s be honest, we all wait for positive feedback. Or at least constructive feedback. If someone posts a comment with words like “great” or “awesome”, we might want to accept them faster than the others.

For such messages, we want to be alerted on an instant messaging system like Slack or Telegram in addition to the regular email.

Install Slack support for Symfony Notifier:

  1. $ symfony composer req slack-notifier

To get started, compose the Slack DSN with a Slack access token and the Slack channel identifier where you want to send messages: slack://ACCESS_TOKEN@default?channel=CHANNEL.

As the access token is sensitive, store the Slack DSN in the secret store:

  1. $ symfony console secrets:set SLACK_DSN

Do the same for production:

  1. $ APP_ENV=prod symfony console secrets:set SLACK_DSN

Enable the chatter Slack support:

patch_file

  1. --- a/config/packages/notifier.yaml
  2. +++ b/config/packages/notifier.yaml
  3. @@ -1,7 +1,7 @@
  4. framework:
  5. notifier:
  6. - #chatter_transports:
  7. - # slack: '%env(SLACK_DSN)%'
  8. + chatter_transports:
  9. + slack: '%env(SLACK_DSN)%'
  10. # telegram: '%env(TELEGRAM_DSN)%'
  11. #texter_transports:
  12. # twilio: '%env(TWILIO_DSN)%'

Update the Notification class to route messages depending on the comment text content (a simple regex will do the job):

patch_file

  1. --- a/src/Notification/CommentReviewNotification.php
  2. +++ b/src/Notification/CommentReviewNotification.php
  3. @@ -7,6 +7,7 @@ use Symfony\Component\Notifier\Message\EmailMessage;
  4. use Symfony\Component\Notifier\Notification\EmailNotificationInterface;
  5. use Symfony\Component\Notifier\Notification\Notification;
  6. use Symfony\Component\Notifier\Recipient\EmailRecipientInterface;
  7. +use Symfony\Component\Notifier\Recipient\RecipientInterface;
  8. class CommentReviewNotification extends Notification implements EmailNotificationInterface
  9. {
  10. @@ -29,4 +30,15 @@ class CommentReviewNotification extends Notification implements EmailNotificatio
  11. return $message;
  12. }
  13. +
  14. + public function getChannels(RecipientInterface $recipient): array
  15. + {
  16. + if (preg_match('{\b(great|awesome)\b}i', $this->comment->getText())) {
  17. + return ['email', 'chat/slack'];
  18. + }
  19. +
  20. + $this->importance(Notification::IMPORTANCE_LOW);
  21. +
  22. + return ['email'];
  23. + }
  24. }

We have also changed the importance of “regular” comments as it slightly tweaks the design of the email.

As for email, you can implement ChatNotificationInterface to override the default rendering of the Slack message:

patch_file

It is better, but let’s go one step further. Wouldn’t it be awesome to be able to accept or reject a comment directly from Slack?

Change the notification to accept the review URL and add two buttons in the Slack message:

patch_file

  1. --- a/src/Notification/CommentReviewNotification.php
  2. +++ b/src/Notification/CommentReviewNotification.php
  3. @@ -3,6 +3,7 @@
  4. namespace App\Notification;
  5. use App\Entity\Comment;
  6. +use Symfony\Component\Notifier\Bridge\Slack\Block\SlackActionsBlock;
  7. use Symfony\Component\Notifier\Bridge\Slack\Block\SlackDividerBlock;
  8. use Symfony\Component\Notifier\Bridge\Slack\Block\SlackSectionBlock;
  9. use Symfony\Component\Notifier\Bridge\Slack\SlackOptions;
  10. @@ -17,10 +18,12 @@ use Symfony\Component\Notifier\Recipient\RecipientInterface;
  11. private $comment;
  12. + private $reviewUrl;
  13. - public function __construct(Comment $comment)
  14. + public function __construct(Comment $comment, string $reviewUrl)
  15. {
  16. $this->comment = $comment;
  17. + $this->reviewUrl = $reviewUrl;
  18. parent::__construct('New comment posted');
  19. }
  20. @@ -53,6 +56,10 @@ class CommentReviewNotification extends Notification implements EmailNotificatio
  21. ->block((new SlackSectionBlock())
  22. ->text(sprintf('%s (%s) says: %s', $this->comment->getAuthor(), $this->comment->getEmail(), $this->comment->getText()))
  23. )
  24. + ->block((new SlackActionsBlock())
  25. + ->button('Accept', $this->reviewUrl, 'primary')
  26. + ->button('Reject', $this->reviewUrl.'?reject=1', 'danger')
  27. + )
  28. );
  29. return $message;

It is now a matter of tracking changes backward. First, update the message handler to pass the review URL:

patch_file

  1. --- a/src/MessageHandler/CommentMessageHandler.php
  2. +++ b/src/MessageHandler/CommentMessageHandler.php
  3. @@ -60,7 +60,8 @@ class CommentMessageHandler implements MessageHandlerInterface
  4. $this->bus->dispatch($message);
  5. } elseif ($this->workflow->can($comment, 'publish') || $this->workflow->can($comment, 'publish_ham')) {
  6. - $this->notifier->send(new CommentReviewNotification($comment), ...$this->notifier->getAdminRecipients());
  7. + $notification = new CommentReviewNotification($comment, $message->getReviewUrl());
  8. + $this->notifier->send($notification, ...$this->notifier->getAdminRecipients());
  9. } elseif ($this->workflow->can($comment, 'optimize')) {
  10. if ($comment->getPhotoFilename()) {
  11. $this->imageOptimizer->resize($this->photoDir.'/'.$comment->getPhotoFilename());

As you can see, the review URL should be part of the comment message, let’s add it now:

patch_file

  1. --- a/src/Message/CommentMessage.php
  2. +++ b/src/Message/CommentMessage.php
  3. @@ -5,14 +5,21 @@ namespace App\Message;
  4. class CommentMessage
  5. {
  6. private $id;
  7. + private $reviewUrl;
  8. private $context;
  9. - public function __construct(int $id, array $context = [])
  10. + public function __construct(int $id, string $reviewUrl, array $context = [])
  11. {
  12. $this->id = $id;
  13. + $this->reviewUrl = $reviewUrl;
  14. $this->context = $context;
  15. }
  16. + public function getReviewUrl(): string
  17. + {
  18. + return $this->reviewUrl;
  19. + }
  20. +
  21. public function getId(): int
  22. {
  23. return $this->id;

Finally, update the controllers to generate the review URL and pass it in the comment message constructor:

patch_file

  1. --- a/src/Controller/AdminController.php
  2. +++ b/src/Controller/AdminController.php
  3. @@ -12,6 +12,7 @@ use Symfony\Component\HttpFoundation\Response;
  4. use Symfony\Component\HttpKernel\KernelInterface;
  5. use Symfony\Component\Messenger\MessageBusInterface;
  6. use Symfony\Component\Routing\Annotation\Route;
  7. +use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
  8. use Symfony\Component\Workflow\Registry;
  9. use Twig\Environment;
  10. @@ -47,7 +48,8 @@ class AdminController extends AbstractController
  11. $this->entityManager->flush();
  12. if ($accepted) {
  13. - $this->bus->dispatch(new CommentMessage($comment->getId()));
  14. + $reviewUrl = $this->generateUrl('review_comment', ['id' => $comment->getId()], UrlGeneratorInterface::ABSOLUTE_URL);
  15. + $this->bus->dispatch(new CommentMessage($comment->getId(), $reviewUrl));
  16. }
  17. return $this->render('admin/review.html.twig', [
  18. --- a/src/Controller/ConferenceController.php
  19. +++ b/src/Controller/ConferenceController.php
  20. @@ -17,6 +17,7 @@ use Symfony\Component\Messenger\MessageBusInterface;
  21. use Symfony\Component\Notifier\Notification\Notification;
  22. use Symfony\Component\Notifier\NotifierInterface;
  23. use Symfony\Component\Routing\Annotation\Route;
  24. +use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
  25. use Twig\Environment;
  26. class ConferenceController extends AbstractController
  27. @@ -82,7 +83,8 @@ class ConferenceController extends AbstractController
  28. 'permalink' => $request->getUri(),
  29. ];
  30. - $this->bus->dispatch(new CommentMessage($comment->getId(), $context));
  31. + $reviewUrl = $this->generateUrl('review_comment', ['id' => $comment->getId()], UrlGeneratorInterface::ABSOLUTE_URL);
  32. + $this->bus->dispatch(new CommentMessage($comment->getId(), $reviewUrl, $context));
  33. $notifier->send(new Notification('Thank you for the feedback; your comment will be posted after moderation.', ['browser']));

Code decoupling means changes in more places, but it makes it easier to test, reason about, and reuse.

Try again, the message should be in good shape now:

Let me explain a slight issue that we should fix. For each comment, we receive an email and a Slack message. If the Slack message errors (wrong channel id, wrong token, …), the messenger message will be retried three times before being discarded. But as the email is sent first, we will receive 3 emails and no Slack messages. One way to fix it is to send Slack messages asynchronously like emails:

patch_file

  1. --- a/config/packages/messenger.yaml
  2. +++ b/config/packages/messenger.yaml
  3. @@ -20,3 +20,5 @@ framework:
  4. # Route your messages to the transports
  5. App\Message\CommentMessage: async
  6. Symfony\Component\Mailer\Messenger\SendEmailMessage: async
  7. + Symfony\Component\Notifier\Message\ChatMessage: async

As soon as everything is asynchronous, messages become independent. We have also enabled asynchronous SMS messages in case you also want to be notified on your phone.

The last task is to notify users when their submission is approved. What about letting you implement that yourself?

Going Further


This work, including the code samples, is licensed under a license.