I'm using the Symfony notifier and messenger components to asynchronously send SMS messages (and in the future push and email notifications).
Everything works just fine, however once a message is sent, I'd like to log information about it.
I can catch a successful message by subscribing to WorkerMessageHandledEvent
which provides me the Message
object, along with the containing Envelope
and all its Stamp
objects inside. From all the available information, I'll be logging this in my database using an entity named MessageLog
.
class MessengerSubscriber implements EventSubscriberInterface {
public static function getSubscribedEvents() {
return [
WorkerMessageHandledEvent::class => ['onHandled']
];
}
public function onHandled(WorkerMessageHandledEvent $event) {
$log = new MessageLog();
$log->setSentAt(new DateTime());
if($event->getEnvelope()->getMessage() instanceof SmsMessage) {
$log->setSubject($event->getEnvelope()->getMessage()->getSubject());
$log->setRecipient($event->getEnvelope()->getMessage()->getPhone());
}
// Do more tracking
}
}
What I'd like to do, is track the object that "invoked" the message. For example, if I have a news feed, and posting a post sends out a notification, I'd like to attribute each logged message to that post (to display audience reach/delivery stats per post - and from an admin POV auditing and reporting).
I've tried to go about adding a Stamp
, or other means of trying to attach custom metadata to the message, but it seems to be abstracted when using the symfony/notifier
bundle.
The below is what I'm using to send notifications (more or less WIP):
class PostService {
protected NotifierInterface $notifier;
public function ___construct(NotifierInterface $notifier) {
$this->notifier = $notifier;
}
public function sendNotifications(Post $post) {
$notification = new PostNotification($post);
$recipients = [];
foreach($post->getNewsFeed()->getSubscribers() as $user) {
$recipients[] = new Recipient($user->getEmail(), $user->getMobilePhone());
}
$this->notifier->send($notification, ...$recipients);
}
}
class PostNotification extends Notification implements SmsNotificationInterface {
protected Post $post;
public function __construct(Post $post) {
parent::__construct();
$this->post = $post;
}
public function getChannels(RecipientInterface $recipient): array {
return ['sms'];
}
public function asSmsMessage(SmsRecipientInterface $recipient, string $transport = null): ?SmsMessage {
if($transport === 'sms') {
return new SmsMessage($recipient->getPhone(), $this->getPostContentAsSms());
}
return null;
}
private function getPostContentAsSms() {
return $post->getTitle()."\n\n".$post->getContent();
}
}
By the time this is all done, this is all I have in the WorkerMessageHandledEvent
^ Symfony\Component\Messenger\Event\WorkerMessageHandledEvent^ {#5590
-envelope: Symfony\Component\Messenger\Envelope^ {#8022
-stamps: array:7 [
"Symfony\Component\Messenger\Stamp\BusNameStamp" => array:1 [
0 => Symfony\Component\Messenger\Stamp\BusNameStamp^ {#10417
-busName: "messenger.bus.default"
}
]
"Symfony\Component\Messenger\Bridge\Doctrine\Transport\DoctrineReceivedStamp" => array:1 [
0 => Symfony\Component\Messenger\Bridge\Doctrine\Transport\DoctrineReceivedStamp^ {#10419
-id: "2031"
}
]
"Symfony\Component\Messenger\Stamp\TransportMessageIdStamp" => array:1 [
0 => Symfony\Component\Messenger\Stamp\TransportMessageIdStamp^ {#10339
-id: "2031"
}
]
"Symfony\Component\Messenger\Stamp\ReceivedStamp" => array:1 [
0 => Symfony\Component\Messenger\Stamp\ReceivedStamp^ {#5628
-transportName: "async"
}
]
"Symfony\Component\Messenger\Stamp\ConsumedByWorkerStamp" => array:1 [
0 => Symfony\Component\Messenger\Stamp\ConsumedByWorkerStamp^ {#7306}
]
"Symfony\Component\Messenger\Stamp\AckStamp" => array:1 [
0 => Symfony\Component\Messenger\Stamp\AckStamp^ {#7159
-ack: Closure(Envelope $envelope, Throwable $e = null)^ {#6205
class: "Symfony\Component\Messenger\Worker"
this: Symfony\Component\Messenger\Worker {#5108 …}
use: {
$transportName: "async"
$acked: & false
}
}
}
]
"Symfony\Component\Messenger\Stamp\HandledStamp" => array:1 [
0 => Symfony\Component\Messenger\Stamp\HandledStamp^ {#11445
-result: Symfony\Component\Notifier\Message\SentMessage^ {#2288
-original: Symfony\Component\Notifier\Message\NullMessage^ {#6625
-decoratedMessage: Symfony\Component\Notifier\Message\SmsMessage^ {#10348
-transport: null
-subject: ".................................................."
-phone: "0412345678"
}
}
-transport: "null"
-messageId: null
}
-handlerName: "Symfony\Component\Notifier\Messenger\MessageHandler::__invoke"
}
]
]
-message: Symfony\Component\Notifier\Message\SmsMessage^ {#10348}
}
-receiverName: "async"
}
The doco shows me ways to add my own stamps to the envelope, which I'm guessing I can use to attach metadata such as my Post
object, but this means I need to use the MessageBusInterface
to send notifications. I don't want to do because I would like to route messages through the NotifierInterface
to gain all the benefits of channel policies, texter transports, etc.
tl;dr: how do I get some metadata through to a WorkerMessageHandledEvent
if I send a message using the NotifierInterface
I've found a way to make it work!
Essentially what happens is that we have two components here, the Symfony notifier and the Symfony messenger. When used together, they create a powerful way to send messages to any number of endpoints.
Firstly what I did was create an interface called NotificationStampsInterface
and a trait called NotificationStamps
that satisfies the interface (by storing a protected array using the interface methods to read/write to it).
class NotificationStampsInterface {
public function getStamps(): array;
public function addStamp(StampInterface $stamp);
public function removeStamp(StampInterface $stamp);
}
This interface can then be added onto your custom notification object, in this instance PostNotification
, alongside with the NotificationStamps
trait to satisfy the interface methods.
The trick here is that when sending a notification via the notifier, it ultimately calls on the messenger component to send the message. The bit that handles this is Symfony\Component\Notifier\Channel\SmsChannel
. Essentially, if a MessageBus is available, it will push messages through that rather than going straight though the notifier.
We can extend the SmsChannel
class to add our own logic inside notify()
method.
class SmsNotify extends \Symfony\Component\Notifer\Channel\SmsChannel {
public function notify(Notification $notification, RecipientInterface $recipient, string $transportName = null): void {
$message = null;
if ($notification instanceof SmsNotificationInterface) {
$message = $notification->asSmsMessage($recipient, $transportName);
}
if (null === $message) {
$message = SmsMessage::fromNotification($notification, $recipient);
}
if (null !== $transportName) {
$message->transport($transportName);
}
if (null === $this->bus) {
$this->transport->send($message);
} else {
// New logic
if($notification instanceof NotificationStampsInterface) {
$envelope = Envelope::wrap($message, $notification->getStamps());
$this->bus->dispatch($envelope);
} else {
$this->bus->dispatch($message);
}
// Old logic
// $this->bus->dispatch($message);
}
}
}
Lastly we need to override the service by adding the following in services.yaml
notifier.channel.sms:
class: App\Notifier\Channel\SmsChannel
arguments: ['@texter.transports', '@messenger.default_bus']
tags:
- { name: notifier.channel, channel: sms }
And that's it! We now have a way to append stamps to our Notification object that will carry all the way through to the WorkerMessageHandledEvent
.
An example use would be (for my situation at least)
class RelatedEntityStamp implements StampInterface {
private string $className;
private int $classId;
public function __construct(object $entity) {
$this->className = get_class($entity);
$this->classId = $entity->getId();
}
/**
* @return string
*/
public function getClassName(): string {
return $this->className;
}
/**
* @return int
*/
public function getClassId(): int {
return $this->classId;
}
}
class PostService {
protected NotifierInterface $notifier;
public function ___construct(NotifierInterface $notifier) {
$this->notifier = $notifier;
}
public function sendNotifications(Post $post) {
$notification = new PostNotification($post);
$stamp = new RelatedEntityStamp($post); // Solution
$notification->addStamp($stamp); // Solution
$recipients = [];
foreach($post->getNewsFeed()->getSubscribers() as $user) {
$recipients[] = new Recipient($user->getEmail(), $user->getMobilePhone());
}
$this->notifier->send($notification, ...$recipients);
}
}
Once the message is sent, dumping the result shows that we do indeed have our stamp registered at the point where our event fires.
^ Symfony\Component\Messenger\Event\WorkerMessageHandledEvent^ {#1078
-envelope: Symfony\Component\Messenger\Envelope^ {#1103
-stamps: array:8 [
"App\Notification\Stamp\RelatedEntityStamp" => array:1 [
0 => App\Notification\Stamp\RelatedEntityStamp^ {#1062
-className: "App\Entity\Post"
-classId: 207
}
]
"Symfony\Component\Messenger\Stamp\BusNameStamp" => array:1 [
0 => Symfony\Component\Messenger\Stamp\BusNameStamp^ {#1063
-busName: "messenger.bus.default"
}
]
"Symfony\Component\Messenger\Bridge\Doctrine\Transport\DoctrineReceivedStamp" => array:1 [
0 => Symfony\Component\Messenger\Bridge\Doctrine\Transport\DoctrineReceivedStamp^ {#1066
-id: "2590"
}
]
"Symfony\Component\Messenger\Stamp\TransportMessageIdStamp" => array:1 [
0 => Symfony\Component\Messenger\Stamp\TransportMessageIdStamp^ {#1067
-id: "2590"
}
]
"Symfony\Component\Messenger\Stamp\ReceivedStamp" => array:1 [
0 => Symfony\Component\Messenger\Stamp\ReceivedStamp^ {#1075
-transportName: "async"
}
]
"Symfony\Component\Messenger\Stamp\ConsumedByWorkerStamp" => array:1 [
0 => Symfony\Component\Messenger\Stamp\ConsumedByWorkerStamp^ {#1076}
]
"Symfony\Component\Messenger\Stamp\AckStamp" => array:1 [
0 => Symfony\Component\Messenger\Stamp\AckStamp^ {#1077
-ack: Closure(Envelope $envelope, Throwable $e = null)^ {#1074
class: "Symfony\Component\Messenger\Worker"
this: Symfony\Component\Messenger\Worker {#632 …}
use: {
$transportName: "async"
$acked: & false
}
}
}
]
"Symfony\Component\Messenger\Stamp\HandledStamp" => array:1 [
0 => Symfony\Component\Messenger\Stamp\HandledStamp^ {#1101
-result: Symfony\Component\Notifier\Message\SentMessage^ {#1095
-original: Symfony\Component\Notifier\Message\NullMessage^ {#1091
-decoratedMessage: Symfony\Component\Notifier\Message\SmsMessage^ {#1060
-transport: null
-subject: ".................................................."
-phone: "0412345678"
}
}
-transport: "null"
-messageId: null
}
-handlerName: "Symfony\Component\Notifier\Messenger\MessageHandler::__invoke"
}
]
]
-message: Symfony\Component\Notifier\Message\SmsMessage^ {#1060}
}
-receiverName: "async"
}