Gateways
Gateways are responsible for actually sealing and then sending a Parcel
and issuing a Receipt
for it.
The Notification Center ships with a MailerGateway
which sends a Parcel
using the Symfony Mailer.
Hence, the basic logic looks like this:
class MailerGateway implements GatewayInterface
{
public const NAME = 'mailer';
public function __construct(private MailerInterface $mailer)
{
}
public function getName(): string
{
return self::NAME;
}
public function sealParcel(Parcel $parcel): Parcel
{
return $parcel
->seal()
->withStamp($this->createEmailStamp($parcel))
;
}
public function sendParcel(Parcel $parcel): Receipt
{
$email = $this->createEmail($parcel->getStamp(EmailStamp::class));
try {
$this->mailer->send($email);
return Receipt::createForSuccessfulDelivery($parcel);
} catch (TransportExceptionInterface $e) {
return Receipt::createForUnsuccessfulDelivery(
$parcel,
CouldNotDeliverParcelException::becauseOfGatewayException(
self::NAME,
0,
$e
)
);
}
}
private function createEmailStamp(Parcel $parcel): EmailStamp
{
// Create a stamp that contains all we need to actually send the e-mail
}
private function createEmail(EmailStamp $emailStamp): Symfony\Component\Mime\Email
{
// Create a Symfony Email instance based on our immutable EmailStamp
}
}
⚠️ Notice how
$parcel->seal()
is called before adding theEmailStamp
. This is an important design decision as it allows to make a difference between stamps that were added before and after the sealing process. The difference is that if you call$parcel->unseal()
, theEmailStamp
will not be present on the parcel. Only the stamps that have been added before are.
🚨 Let’s talk about the single most important design decision when creating your own gateway which you
absolutely have to keep in mind: Your gateway must not rely on dynamic information in the sendParcel()
method. It must be immutable. Let’s take the post office analogy: When you prepare your parcel, you can stick as many stamps and labels to
it. You can put placeholder stamps, unpack it, change its content, hand it to your friend to add more content or their own
labels etc. All of which is represented in the Notification Center by the CreateParcelEvent
. However,
once you go to the counter and you actually want to send the parcel, you have to create one final version it. You cannot send it with ##receiver_name##
written on it and it cannot be sent when still open.
Thus, the parcel must be sealed. This is what you do in your sealParcel()
method. Basically, this
is the one that does the heavy work. In most cases, you will take all the stamps, process them the way you want and
add another immutable stamp that sendParcel()
will then use. This is exactly what happens in the example above.
The best way to think about this architectural design is to imagine that sealParcel()
does not happen on the
same server as sendParcel()
. This will clarify that everything sendParcel()
requires, must be part of your
Parcel
and its stamps.
Typical design issues may include:
- Accessing the current request via the
RequestStack
in thesendParcel()
method. That is not allowed! If you need something from the current request, it’s best to create a stamp for that. Use theCreateParcelEvent
for it. - Replacing insert tags in the
sendParcel()
method. This must happen in thesealParcel()
method. An insert tag could be e.g.{{env::request}}
which contains the URL of the current page. This might not exist duringsendParcel()
because it happens later/on a different server etc. Make sure you replace that information when sealing the parcel.
You can also extend AbstractGateway
which provides helpers if your gateway e.g. requires certain stamps
to be present on your Parcel
. E.g. the MailerGateay
requires a LanguageConfigStamp
to be present during the
sealParcel()
stage, because it expects language specific information. And it expects an EmailStamp
during
the sendParcel()
stage. However, the TokenCollectionStamp
is optional - it’s also perfectly able
to send a Parcel
without any token replacements.
Maybe you want to write a SlackGateway
and you need some kind of SlackTargetChannelStamp
?
The AbstractGateway
also provides a simple replaceTokens(Parcel $parcel, string $value)
method which will replace
tokens in case your Gateway was provided with Contao’s SimpleTokenParser
and the parcel has a TokenCollectionStamp
.
In order to make your new gateway known to the Notification Center, you have to register it as a
service and tag it using the notification_center.gateway
tag. If you use the [autoconfiguration
feature of the Symfony Container][DI_Autoconfigure], you don’t need to tag the service. Implementing the
GatewayInterface
will be enough.
Asynchronous Gateways
When a parcel is given to a Gateway, there’s an immediate response on the counter which is called the Receipt
, we have
already learned about that. And this delivery can be either successful or unsuccessful. Either you managed to hand over
your parcel on the counter, or you didn’t because e.g. the nice employee told you, you forgot to add a certain Stamp
.
Now, what happens to that parcel after you have successfully delivered it to the counter? Exactly, so far, we have no clue.
The immediate Receipt
only tells us whether our parcel has been accepted by the Gateway or not. But we are also interested
in whether the parcel was actually delivered to the final destination which can be minutes, days or weeks later.
Enter the AsynchronousReceipt
.
The MailerGateway
of the Notification Center uses this feature too because by default, Contao uses the Symfony Mailer
and sending the e-mails happens asynchronously using Symfony Messenger. This means that Contao is going to try to send
the e-mail in the background for a few times in order to work around temporary network outages etc.
Hence, when sealing the package, the MailerGateway
adds another stamp in order to inform any third-party event listeners
about the fact, that this parcel will get asynchronous information:
return $parcel
->seal()
->withStamp(AsynchronousDeliveryStamp::createWithRandomId())
;
Now, any listener can access this stamp using $event->receipt->getParcel()->getStamp(AsynchronousDeliveryStamp::class)?->identifier
in any of the events and store this identifier for further processing.
The MailerGatway
itself passes this identifier as a header on the Email
instance and - because it uses the Symfony Mailer -
registers to the Symfony Mailer SentMessageEvent
and the FailedMessageEvent
. This allows it to extract the header, remove
it from the final Email
and inform any third-party integrators about the fact, that this e-mail now has been sent.
Doing this is pretty straightforward:
$receipt = $error
? AsynchronousReceipt::createForUnsuccessfulDelivery($messageId, $error)
: AsynchronousReceipt::createForSuccessfulDelivery($messageId);
$this->notificationCenter->informAboutAsynchronousReceipt($receipt);
The NotificationCenter::informAboutAsynchronousReceipt()
does nothing more than dispatching an AsynchronousReceiptEvent
so listening to it is enough to get informed about any AsynchronousReceipt
.
As you can see, you can enhance your Gateway with asynchronous capabilities in order to inform third-party developers about
the fact that your Parcel
is actually sent asynchronously, you gave it an identifier, and you will inform them as soon
as you know the asynchronous process has finished.