Symfony Webhook & RemoteEvent, or how to simplify external event management
During the SymfonyCon 2022 conference at Disneyland Paris, Fabien Potencier unveiled two new components to the Symfony community: Webhook and RemoteEvent.
They have recently (March 2023) been merged into the 6.3 branch, so we can start testing them, which is what we’ll do in the following article!
Section intitulée webhookWebhook
A webhook allows a service outside your application to send you events. A webhook is an HTTP call from server to server. The format of the request sent is often specified by the sender of the request. For example, with Stripe, when the event checkout.session.completed
occurs, Stripe will send as many webhooks as necessary. To receive a webhook, we need to configure Stripe with an URL from our application.
Let’s see concretely, with the help of a demo application, how we can receive these webhooks with a minimum of code.
Section intitulée ensure-an-email-is-deliveredEnsure an email is delivered
In this demo application, we will send an email using the Postmark service, and configure the reception of the Postmark webhook that will inform us of the delivery of our email.
Note: In order to simplify the demonstration, we have voluntarily chosen not to make an example with Stripe or Slack.
Let’s create a new Symfony application by specifying the --version=next
option to install the future version 6.3.
symfony new --webapp --version=next webhook-demo
We will need the Bridge Mailer Postmark, in order to send emails easily via this service.
symfony composer require symfony/postmark-mailer
Then let’s install the new Webhook component.
symfony composer require symfony/webhook
⚠ Since these components are very new, they don’t have Flex Recipes yet in March 2023, but they will be available when Symfony 6.3 is released. So for now, we have to configure them manually.
First, in the config/routes.yaml
file, let’s add the few lines that tell our application how to use the WebhookController
provided by the component:
# …
webhook:
resource: '@FrameworkBundle/Resources/config/routing/webhook.xml'
prefix: /webhook
Here we also declare that the prefix of all our webhook URLs will be /webhook
.
Then, in a new webhook.yaml
file, let’s add the following code:
framework:
webhook:
routing:
postmark: # this string will be used to build the webhook reception URL
service: mailer.webhook.request_parser.postmark
This configuration tells the Webhook component that we want to expose the URL /webhook/postmark
. And upon receiving a request on this URL we want to use the mailer.webhook.request_parser.postmark
service.
Let’s have a look at this service, which is not provided by the Webhook component but by the symfony/postmark-mailer package. Indeed, Fabien has started to natively integrate webhook management in two Symfony Mailer Bridges: Postmark and MailGun. In the future, we can imagine that most of the Notifier and Mailer Bridges will have everything needed to manage webhooks sent by the corresponding services.
This service, whose FQCN is Symfony\Component\Mailer\Bridge\Postmark\Webhook\PostmarkRequestParser
, allows the Webhook component to check that requests arriving on the previously defined URL are legitimate and valid.
The RequestParserInterface
:
interface RequestParserInterface
{
/**
* Parses an HTTP Request and converts it into a RemoteEvent.
*
* @return ?RemoteEvent Returns null if the webhook must be ignored
*
* @throws RejectWebhookException When the payload is rejected (signature issue, parse issue, ...)
*/
public function parse(Request $request, string $secret): ?RemoteEvent;
public function createSuccessfulResponse(): Response;
public function createRejectedResponse(string $reason): Response;
}
And its implementation for Postmark :
namespace Symfony\Component\Mailer\Bridge\Postmark\Webhook;
final class PostmarkRequestParser extends AbstractRequestParser
{
public function __construct(
private readonly PostmarkPayloadConverter $converter,
) {
}
protected function getRequestMatcher(): RequestMatcherInterface
{
return new ChainRequestMatcher([
new MethodRequestMatcher('POST'),
// https://postmarkapp.com/support/article/800-ips-for-firewalls#webhooks
// localhost is added for testing
new IpsRequestMatcher(['3.134.147.250', '50.31.156.6', '50.31.156.77', '18.217.206.57', '127.0.0.1', '::1']),
new IsJsonRequestMatcher(),
]);
}
protected function doParse(Request $request, string $secret): ?AbstractMailerEvent
{
$payload = $request->toArray();
if (
!isset($payload['RecordType'])
|| !isset($payload['MessageID'])
|| !(isset($payload['Recipient']) || isset($payload['Email']))
|| !isset($payload['Metadata'])
|| !isset($payload['Tag'])
) {
throw new RejectWebhookException(406, 'Payload is malformed.');
}
try {
return $this->converter->convert($payload);
} catch (ParseException $e) {
throw new RejectWebhookException(406, $e->getMessage(), $e);
}
}
}
To perform these validations, PostmarkRequestParser
uses the RequestMatcher mechanism included in the HTTPFoundation component. Practically, this mechanism proposes classes to check that the IP sending the request is included in a defined list, or that the request content is in JSON, or that the HTTP verb is POST
. Fabien preferred to use what already existed rather than reinventing a similar system.
If this first step is successful, then the component triggers the doParse
method. This method will validate the content of the request, and make sure that all the expected data are present, then, if necessary, call a new service, the Payload Converter, which will transform our payload into an object.
Section intitulée remoteeventRemoteEvent
This is where the RemoteEvent component comes into light. Its role is to convert the data received in the webhooks into validated objects that we can then use as we like.
From the HTTP request received by our PostmarkRequestParser
service, the Payload Converter will create a RemoteEvent
object, and even more precisely in our case, a MailerDeliveryEvent
object.
🗒 Note here that several classes inheriting from RemoteEvent
are already available to us in the component code.
As for the Webhook part, the Postmark Bridge provides a PostmarkPayloadConverter
service.
Here, we don’t have to do anything in our code since it is the PostmarkRequestParser
that calls PostmarkPayloadConverter
.
The only PHP file we need to create to access this MailerDeliveryEvent
object is a PostmarkConsumer
.
use App\Message\MarkEmailAsDeliveredMessage;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\RemoteEvent\Attribute\AsRemoteEventConsumer;
use Symfony\Component\RemoteEvent\Event\Mailer\MailerDeliveryEvent;
#[AsRemoteEventConsumer(name: 'postmark')]
class PostmarkConsumer
{
public function __construct(
public readonly TransactionalEmailRepository $transactionalEmailRepository,
public readonly EntityManagerInterface $em,
) {
}
public function consume(MailerDeliveryEvent $event): void
{
$transactionalEmail = $this->transactionalEmailRepository->findOneBy(['messageIdentifier' => $event->getMetadata()['Message-ID']]); // "Message-ID" is a metadata field defined when sending the email. Postmark sends it back in the webhook, as the email identifier.
$transactionalEmail->setDelivered(true);
$this->em->persist($transactionalEmail);
$this->em->flush();
}
}
Be aware of several things in this file. First, the AsRemoteEventConsumer
attribute, which allows us to declare this class as a RemoteEventConsumer
and thus, make it known to the RemoteEvent component so that it can pass the converted object to it. Then, the name
is also important, it must be equal to the configuration entry under routing
that we entered in the webhook.yaml file, which in our case is postmark
.
In the consume
method, we can finally have our object containing the data of the event that triggers the webhook, and act accordingly.
For the demonstration, we chose to store each email sent into the database, and to mark them as delivered upon reception of the webhook issued by Postmark.
Section intitulée source-codeSource code
The source code of the demo application is available on our GitHub. To use it you will first need a Postmark account configured on a dedicated domain (validation of DKIM and Return-Path DNS entries is required).
On this repository, there is a difference with the code presented in the article; we chose to use Messenger to make the processing of the received RemoteEvent asynchronous. Indeed, it seems important to us to respond as quickly as possible, in the HTTP sense of the term, to the external service and that it is not dependent on the processing time. However, some services such as Stripe expect our application to be able to respond to a 500 error for example. In this case, it is possible that Stripe will replay the webhook a few moments later.
Section intitulée your-turn-to-playYour turn to play!
As we just saw, these two new components provide all the necessary infrastructure to receive webhooks from any service.
If your applications manage webhooks from Slack, Discord, Stripe, PayPal or your CRM, don’t hesitate to contribute them to Symfony to benefit the community and enrich all the existing Bridges for Mailer, Notifier and why not Translation 😉
Commentaires et discussions
SymfonyCon at Disneyland Paris for the 15+2th birthday of Symfony
After more than two years of waiting, SymfonyCon 2020 2021 2022 was held at Disneyland Paris on November 17th and 18th. We were delighted to meet community members, other members of the Core Team, as well as contributors who have been involved in Symfony for years. The venue, very…
Lire la suite de l’article SymfonyCon at Disneyland Paris for the 15+2th birthday of Symfony
Nos articles sur le même sujet
Nos formations sur ce sujet
Notre expertise est aussi disponible sous forme de formations professionnelles !
Symfony
Formez-vous à Symfony, l’un des frameworks Web PHP les complet au monde
Symfony avancée
Découvrez les fonctionnalités et concepts avancés de Symfony
Ces clients ont profité de notre expertise
Travailler sur un projet US présente plusieurs défis. En premier lieu : Le décalage horaire. Afin d’atténuer cet obstacle, nous avons planifié les réunions en début d’après-midi en France, ce qui permet de trouver un compromis acceptable pour les deux parties. Cette approche assure une participation optimale des deux côtés et facilite la communication…
Nous avons entrepris une refonte complète du site, initialement développé sur Drupal, dans le but de le consolider et de jeter les bases d’un avenir solide en adoptant Symfony. La plateforme est hautement sophistiquée et propose une pléthore de fonctionnalités, telles que la gestion des abonnements avec Stripe et Paypal, une API pour l’application…
La société AramisAuto a fait appel à JoliCode pour développer au forfait leur plateforme B2B. L’objectif était de développer une nouvelle offre à destination des professionnels ; déjà testé commercialement, pro.aramisauto.com est la concrétisation de 3 mois de développement. Le service est indépendant de l’infrastructure existante grâce à la mise en…