|
| 1 | +--- |
| 2 | +title: "Gateways" |
| 3 | +--- |
| 4 | + |
| 5 | +Gateways are responsible for actually sealing and then sending a `Parcel` and issuing a `Receipt` for it. |
| 6 | +The Notification Center ships with a `MailerGateway` which sends a `Parcel` using the Symfony Mailer. |
| 7 | +Hence, the basic logic looks like this: |
| 8 | + |
| 9 | +```php |
| 10 | +class MailerGateway implements GatewayInterface |
| 11 | +{ |
| 12 | + public const NAME = 'mailer'; |
| 13 | + |
| 14 | + public function __construct(private MailerInterface $mailer) |
| 15 | + { |
| 16 | + |
| 17 | + } |
| 18 | + |
| 19 | + public function getName(): string |
| 20 | + { |
| 21 | + return self::NAME; |
| 22 | + } |
| 23 | + |
| 24 | + public function sealParcel(Parcel $parcel): Parcel |
| 25 | + { |
| 26 | + return $parcel |
| 27 | + ->seal() |
| 28 | + ->withStamp($this->createEmailStamp($parcel)) |
| 29 | + ; |
| 30 | + } |
| 31 | + |
| 32 | + public function sendParcel(Parcel $parcel): Receipt |
| 33 | + { |
| 34 | + $email = $this->createEmail($parcel->getStamp(EmailStamp::class)); |
| 35 | + |
| 36 | + try { |
| 37 | + $this->mailer->send($email); |
| 38 | + |
| 39 | + return Receipt::createForSuccessfulDelivery($parcel); |
| 40 | + } catch (TransportExceptionInterface $e) { |
| 41 | + return Receipt::createForUnsuccessfulDelivery( |
| 42 | + $parcel, |
| 43 | + CouldNotDeliverParcelException::becauseOfGatewayException( |
| 44 | + self::NAME, |
| 45 | + 0, |
| 46 | + $e |
| 47 | + ) |
| 48 | + ); |
| 49 | + } |
| 50 | + } |
| 51 | + |
| 52 | + private function createEmailStamp(Parcel $parcel): EmailStamp |
| 53 | + { |
| 54 | + // Create a stamp that contains all we need to actually send the e-mail |
| 55 | + } |
| 56 | + |
| 57 | + private function createEmail(EmailStamp $emailStamp): Symfony\Component\Mime\Email |
| 58 | + { |
| 59 | + // Create a Symfony Email instance based on our immutable EmailStamp |
| 60 | + } |
| 61 | +} |
| 62 | +``` |
| 63 | + |
| 64 | +> ⚠️ Notice how `$parcel->seal()` is called **before** adding the `EmailStamp`. This is an important design decision |
| 65 | +> as it allows to make a difference between stamps that were added before and after the sealing process. The difference |
| 66 | +> is that if you call `$parcel->unseal()`, the `EmailStamp` will not be present on the parcel. Only the stamps that have |
| 67 | +> been added before are. |
| 68 | +
|
| 69 | +🚨 Let's talk about **the single most important** design decision when creating your own gateway which you |
| 70 | +absolutely have to keep in mind: Your gateway **must not** rely on dynamic information in the `sendParcel()` |
| 71 | +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 |
| 72 | +it. You can put placeholder stamps, unpack it, change its content, hand it to your friend to add more content or their own |
| 73 | +labels etc. All of which is represented in the Notification Center by the `CreateParcelEvent`. However, |
| 74 | +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. |
| 75 | +Thus, the parcel must be sealed. This is what you do in your `sealParcel()` method. Basically, this |
| 76 | +is the one that does the heavy work. In most cases, you will take all the stamps, process them the way you want and |
| 77 | +add another **immutable** stamp that `sendParcel()` will then use. This is exactly what happens in the example above. |
| 78 | + |
| 79 | +The best way to think about this architectural design is to imagine that `sealParcel()` does not happen on the |
| 80 | +same server as `sendParcel()`. This will clarify that everything `sendParcel()` requires, must be part of your |
| 81 | +`Parcel` and its stamps. |
| 82 | + |
| 83 | +Typical design issues may include: |
| 84 | + |
| 85 | +* Accessing the current request via the `RequestStack` in the `sendParcel()` method. That is not allowed! If you |
| 86 | + need something from the current request, it's best to create a stamp for that. Use the `CreateParcelEvent` for it. |
| 87 | +* Replacing insert tags in the `sendParcel()` method. This must happen in the `sealParcel()` method. An insert tag |
| 88 | + could be e.g. `{{env::request}}` which contains the URL of the current page. This might not exist during `sendParcel()` |
| 89 | + because it happens later/on a different server etc. Make sure you replace that information when sealing the parcel. |
| 90 | + |
| 91 | +You can also extend `AbstractGateway` which provides helpers if your gateway e.g. requires certain stamps |
| 92 | +to be present on your `Parcel`. E.g. the `MailerGateay` requires a `LanguageConfigStamp` to be present during the |
| 93 | +`sealParcel()` stage, because it expects language specific information. And it expects an `EmailStamp` during |
| 94 | +the `sendParcel()` stage. However, the `TokenCollectionStamp` is optional - it's also perfectly able |
| 95 | +to send a `Parcel` without any token replacements. |
| 96 | + |
| 97 | +Maybe you want to write a `SlackGateway` and you need some kind of `SlackTargetChannelStamp`? |
| 98 | + |
| 99 | +The `AbstractGateway` also provides a simple `replaceTokens(Parcel $parcel, string $value)` method which will replace |
| 100 | +tokens in case your Gateway was provided with Contao's `SimpleTokenParser` and the parcel has a `TokenCollectionStamp`. |
| 101 | + |
| 102 | +In order to make your new gateway known to the Notification Center, you have to register it as a |
| 103 | +service and tag it using the `notification_center.gateway` tag. If you use the [autoconfiguration |
| 104 | +feature of the Symfony Container][DI_Autoconfigure], you don't need to tag the service. Implementing the |
| 105 | +`GatewayInterface` will be enough. |
| 106 | + |
| 107 | +## Asynchronous Gateways |
| 108 | + |
| 109 | +When a parcel is given to a Gateway, there's an immediate response on the counter which is called the `Receipt`, we have |
| 110 | +already learned about that. And this delivery can be either successful or unsuccessful. Either you managed to hand over |
| 111 | +your parcel on the counter, or you didn't because e.g. the nice employee told you, you forgot to add a certain `Stamp`. |
| 112 | + |
| 113 | +Now, what happens to that parcel after you have successfully delivered it to the counter? Exactly, so far, we have no clue. |
| 114 | +The immediate `Receipt` only tells us whether our parcel has been accepted by the Gateway or not. But we are also interested |
| 115 | +in whether the parcel was actually delivered to the final destination which can be minutes, days or weeks later. |
| 116 | + |
| 117 | +Enter the `AsynchronousReceipt`. |
| 118 | + |
| 119 | +The `MailerGateway` of the Notification Center uses this feature too because by default, Contao uses the Symfony Mailer |
| 120 | +and sending the e-mails happens asynchronously using Symfony Messenger. This means that Contao is going to try to send |
| 121 | +the e-mail in the background for a few times in order to work around temporary network outages etc. |
| 122 | + |
| 123 | +Hence, when sealing the package, the `MailerGateway` adds another stamp in order to inform any third-party event listeners |
| 124 | +about the fact, that this parcel will get asynchronous information: |
| 125 | + |
| 126 | +```php |
| 127 | +return $parcel |
| 128 | + ->seal() |
| 129 | + ->withStamp(AsynchronousDeliveryStamp::createWithRandomId()) |
| 130 | +; |
| 131 | +``` |
| 132 | + |
| 133 | +Now, any listener can access this stamp using `$event->receipt->getParcel()->getStamp(AsynchronousDeliveryStamp::class)?->identifier` |
| 134 | +in any of the events and store this identifier for further processing. |
| 135 | + |
| 136 | +The `MailerGatway` itself passes this identifier as a header on the `Email` instance and - because it uses the Symfony Mailer - |
| 137 | +registers to the Symfony Mailer `SentMessageEvent` and the `FailedMessageEvent`. This allows it to extract the header, remove |
| 138 | +it from the final `Email` and inform any third-party integrators about the fact, that this e-mail now has been sent. |
| 139 | +Doing this is pretty straightforward: |
| 140 | + |
| 141 | +```php |
| 142 | +$receipt = $error |
| 143 | + ? AsynchronousReceipt::createForUnsuccessfulDelivery($messageId, $error) |
| 144 | + : AsynchronousReceipt::createForSuccessfulDelivery($messageId); |
| 145 | + |
| 146 | +$this->notificationCenter->informAboutAsynchronousReceipt($receipt); |
| 147 | +``` |
| 148 | + |
| 149 | +The `NotificationCenter::informAboutAsynchronousReceipt()` does nothing more than dispatching an `AsynchronousReceiptEvent` |
| 150 | +so listening to it is enough to get informed about any `AsynchronousReceipt`. |
| 151 | + |
| 152 | +As you can see, you can enhance your Gateway with asynchronous capabilities in order to inform third-party developers about |
| 153 | +the fact that your `Parcel` is actually sent asynchronously, you gave it an identifier, and you will inform them as soon |
| 154 | +as you know the asynchronous process has finished. |
0 commit comments