Processing External Events Type-Safely
Webhooks from Stripe, GitHub, Shopify or your own services arrive as HTTP POST requests, without type safety, without schema guarantees, and potentially from spoofed senders. Symfony offers a complete infrastructure with the Webhook component, Messenger and HMAC signature verification to process external events safely, type-safely and asynchronously.
Table of Contents
- 1. Why webhooks need type-safe processing
- 2. Installing the Symfony Webhook component
- 3. HMAC signature verification: verifying the sender
- 4. Custom WebhookParser classes for different services
- 5. Type-safe webhook event objects with DTOs
- 6. Asynchronous processing with Symfony Messenger
- 7. Retry strategies and error handling
- 8. Idempotency: safely catching duplicate webhooks
- 9. Comparison: webhook processing strategies
- 10. Summary
- 11. FAQ
1. Why webhooks need type-safe processing
An incoming Symfony webhook is initially nothing more than an HTTP POST request with a JSON body. The challenge is not receiving it but processing it safely: Is the request really from Stripe, GitHub, or the expected sender? Does the payload match the expected schema, or has the external service changed its format? Is the request answered synchronously in under 3 seconds, even if the processing logic takes several seconds? Without a clear architecture, you end up with controllers that parse JSON directly, check no signature, and run business logic synchronously in the request lifecycle, a recipe for hard-to-debug errors and security problems.
The right architecture for Symfony webhooks cleanly separates three responsibilities: first, the ingress check (signature validation, HTTP header evaluation, fast 200 response), second, parsing (mapping the JSON payload to type-safe event objects), and third, processing (business logic, database operations, external API calls). Symfony's Webhook component handles the first two steps, and Symfony Messenger handles the third asynchronously. The result: the webhook controller is minimal, the processing logic is independently testable, and errors during processing do not block the ingestion of further webhooks.
Type safety for webhooks concretely means: instead of $payload['data']['object']['amount'] with direct array access (which produces a null value or an undefined index warning when a key is missing), there is a StripePaymentIntentEvent DTO with typed properties. If Stripe sends a webhook with a missing amount field, a clear exception is thrown during deserialization, not silent null handling. This approach makes webhook handling testable, predictable and maintainable.
2. Installing the Symfony Webhook component
The Symfony Webhook component has been available since Symfony 6.3 and is installed via Composer as a separate package. It brings the WebhookController, which serves as the central entry point for all incoming webhooks, as well as the RemoteEvent system, which represents parsed webhook payloads type-safely. For common services such as Stripe, GitHub and Twilio, ready-made parser packages already exist, sparing you from writing your own parsers manually.
The WebhookController is a services-first controller: it is registered via routing configuration under a configurable path (default: /webhook/{type}) and forwards incoming requests to the matching RequestParser. The parser is responsible for a specific service type, checks the signature, extracts the event type label from the HTTP headers, and deserializes the JSON payload into a typed RemoteEvent object. This object is then passed on to a RemoteEventConsumer that contains the business logic, or dispatched asynchronously via Symfony Messenger.
<?php
// Installation commands:
// composer require symfony/webhook
// composer require symfony/messenger # for async processing
// composer require symfony/remote-event # pulled by webhook automatically
// config/routes/webhook.yaml - register the webhook entry controller
// webhook:
// resource: '@WebhookBundle/config/routing.php'
// prefix: /webhook
// Or manually in config/routes.yaml:
// stripe_webhook:
// path: /webhook/stripe
// controller: Symfony\Component\Webhook\Controller\MainController
// defaults: { type: stripe }
// methods: [POST]
// Check available webhook types after install:
// bin/console debug:router | grep webhook
// bin/console debug:container --tag=webhook.request_parser
3. HMAC signature verification: verifying the sender
HMAC signature verification is the most critical security step in Symfony webhook processing. Without it, any client can send POST requests to the webhook endpoint and simulate payment confirmations, order updates or user events. The external service, such as Stripe or GitHub, signs the webhook payload with a shared secret. The receiver computes the same HMAC signature and compares it with the one submitted in the HTTP header. If they differ, the request is rejected with 400 or 403 without ever reaching the business logic.
In Symfony, signature verification is implemented in the RequestParser, specifically in the createRejectedResponse() and parse() methods. The parse() method receives the complete Request object and returns a RemoteEvent, or throws an exception if signature verification fails. Important: the raw request body must be used for the HMAC computation, not the parsed JSON array, because the signature was computed over the byte-exact HTTP body. $request->getContent() returns the raw body as a string.
The HMAC computation itself uses hash_hmac('sha256', $rawBody, $secret) and compares the result with the header value using hash_equals() instead of ===. The latter is critical for security: hash_equals() performs a constant-time comparison that prevents timing attacks, in which an attacker could infer partially correct signatures from response times. This Symfony webhook security step costs one line of code and prevents an entire class of attack vectors.
4. Custom WebhookParser classes for different services
For each external service from which Symfony webhooks are received, you implement your own RequestParserInterface. The class carries the service tag webhook.request_parser with a type attribute that matches the routing type. The most important method is parse(Request $request, #[SensitiveParameter] string $secret): RemoteEvent: it checks the signature, extracts the event type from the header or the body, and creates a typed RemoteEvent object with the deserialized payload.
A realistic scenario: GitHub sends webhooks with the header X-GitHub-Event (event type) and X-Hub-Signature-256 (HMAC-SHA256 signature with a sha256= prefix). The parser reads both headers, checks the signature against the configured secret, and creates a different RemoteEvent object depending on the event type (push, pull_request, release). For services with many event types, a factory or a switch inside the parser makes sense to translate the raw JSON body into the matching typed event DTO.
<?php
declare(strict_types=1);
namespace App\Webhook\Parser;
use App\Webhook\Event\StripePaymentIntentEvent;
use App\Webhook\Event\StripeCheckoutSessionEvent;
use SensitiveParameter;
use Symfony\Component\HttpFoundation\ChainRequestMatcher;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestMatcherInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\RemoteEvent\RemoteEvent;
use Symfony\Component\Webhook\Client\AbstractRequestParser;
use Symfony\Component\Webhook\Exception\RejectWebhookException;
/**
* Parser for incoming Stripe webhook events.
* Validates HMAC-SHA256 signature and maps payload to typed RemoteEvent objects.
*/
final class StripeRequestParser extends AbstractRequestParser
{
/**
* Only match POST requests to avoid processing GET health checks etc.
*/
protected function getRequestMatcher(): RequestMatcherInterface
{
return new ChainRequestMatcher([
new \Symfony\Component\HttpFoundation\RequestMatcher\MethodRequestMatcher('POST'),
]);
}
/**
* Validate the Stripe signature and parse the payload into a typed event.
*
* @throws RejectWebhookException if signature is invalid or payload is malformed
*/
protected function doParse(Request $request, #[SensitiveParameter] string $secret): RemoteEvent
{
// Stripe sends the raw timestamp + payload hash in the Stripe-Signature header
$signatureHeader = $request->headers->get('Stripe-Signature', '');
$rawBody = $request->getContent();
if (!$this->isSignatureValid($rawBody, $signatureHeader, $secret)) {
throw new RejectWebhookException(Response::HTTP_FORBIDDEN, 'Invalid Stripe webhook signature.');
}
$payload = json_decode($rawBody, true, flags: JSON_THROW_ON_ERROR);
$eventType = $payload['type'] ?? null;
if ($eventType === null) {
throw new RejectWebhookException(Response::HTTP_BAD_REQUEST, 'Missing event type in Stripe payload.');
}
// Map Stripe event type to typed DTO, extend for more event types
$eventObject = match (true) {
str_starts_with($eventType, 'payment_intent.') => StripePaymentIntentEvent::fromPayload($payload),
str_starts_with($eventType, 'checkout.session.') => StripeCheckoutSessionEvent::fromPayload($payload),
default => new RemoteEvent($eventType, $payload['id'] ?? uniqid(), $payload),
};
return $eventObject;
}
/**
* Verify Stripe HMAC-SHA256 signature using constant-time comparison.
* Stripe signature format: t=timestamp,v1=signature
*/
private function isSignatureValid(string $rawBody, string $signatureHeader, string $secret): bool
{
// Parse the Stripe-Signature header: "t=1234567890,v1=abc123..."
$parts = [];
foreach (explode(',', $signatureHeader) as $part) {
[$key, $value] = explode('=', $part, 2) + ['', ''];
$parts[$key] = $value;
}
$timestamp = $parts['t'] ?? null;
$v1Signature = $parts['v1'] ?? null;
if ($timestamp === null || $v1Signature === null) {
return false;
}
// Stripe signs: timestamp + '.' + raw_payload
$signedPayload = $timestamp . '.' . $rawBody;
$expected = hash_hmac('sha256', $signedPayload, $secret);
// Constant-time comparison prevents timing attacks
return hash_equals($expected, $v1Signature);
}
}
5. Type-safe webhook event objects with DTOs
Type-safe webhook events in Symfony are DTOs (Data Transfer Objects) that extend RemoteEvent and map all relevant fields of the external payload as typed PHP properties. Instead of $event->getPayload()['data']['object']['amount_received'], there is $event->amountReceived with the type int. This eliminates array access errors at runtime and makes the code fully analyzable for IDEs. If Stripe renames or removes a field, the parser fails immediately, not somewhere in the middle of the processing logic.
The static factory method fromPayload(array $payload) in the DTO is the place where unsafe array data is turned into typed PHP values. Error handling matters here: if a required field is missing, the factory throws a RejectWebhookException so that the controller can respond with 400 instead of passing a null value into the processing logic. Optional fields are represented with nullable types (?string) or default values. With the Symfony Serializer, JSON deserialization into DTOs can also be automated if the DTO classes are fully typed.
For complex webhook payloads, such as Shopify orders with nested line items, addresses and metadata, nested DTOs pay off. The order payload is deserialized into a ShopifyOrderCreatedEvent, which itself contains a list of ShopifyLineItem objects and a ShopifyAddress object. The Symfony Serializer with ObjectNormalizer and type hints handles the nesting automatically if the DTO classes are correctly typed.
6. Asynchronous processing with Symfony Messenger
The golden rule for Symfony webhooks: respond with 200 OK as fast as possible and run the actual processing logic asynchronously. External services such as Stripe or GitHub often have short timeout windows (5 to 30 seconds). If the processing logic, database operations, email sending, external API calls, takes longer, timeouts occur and the external service retries the webhook. This leads to duplicate processing if idempotency is not implemented.
With Symfony Messenger, the RemoteEvent object is written as a message to a queue as soon as signature verification succeeds. The webhook endpoint responds immediately with 200 OK. A background worker process (started with bin/console messenger:consume webhook) reads the messages from the queue and executes the processing logic. If processing fails, the worker automatically retries the message according to the configured retry strategy, without the external service having to send the same webhook again.
<?php
declare(strict_types=1);
namespace App\Webhook\Consumer;
use App\Webhook\Event\StripePaymentIntentEvent;
use App\Service\OrderService;
use Symfony\Component\RemoteEvent\Attribute\AsRemoteEventConsumer;
use Symfony\Component\RemoteEvent\Consumer\ConsumerInterface;
use Symfony\Component\RemoteEvent\RemoteEvent;
/**
* Consumes Stripe payment_intent webhook events and updates order state.
* This class is called asynchronously via Symfony Messenger after signature validation.
*/
#[AsRemoteEventConsumer('stripe')]
final readonly class StripeWebhookConsumer implements ConsumerInterface
{
public function __construct(
private OrderService $orderService,
) {}
/**
* Handle the incoming Stripe RemoteEvent.
* Only processes payment_intent events, others are silently ignored.
*/
public function consume(RemoteEvent $event): void
{
// Only handle typed events we explicitly know about
if (!$event instanceof StripePaymentIntentEvent) {
return;
}
// Dispatch to the correct handler based on Stripe event type
match ($event->getName()) {
'payment_intent.succeeded' => $this->orderService->markAsPaid($event),
'payment_intent.payment_failed' => $this->orderService->markAsPaymentFailed($event),
'payment_intent.canceled' => $this->orderService->markAsCanceled($event),
default => null, // Unknown sub-type, ignore safely
};
}
}
// config/packages/messenger.yaml - route remote events to async transport
// framework:
// messenger:
// transports:
// webhook:
// dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
// retry_strategy:
// max_retries: 3
// delay: 1000
// multiplier: 2
// routing:
// 'Symfony\Component\RemoteEvent\RemoteEvent': webhook
7. Retry strategies and error handling
If the asynchronous processing of a Symfony webhook fails, for example because an external API is unreachable or a database operation fails, the retry strategy of Symfony Messenger kicks in. The default configuration attempts processing at most three times, with exponentially increasing wait times: 1 second, 2 seconds, 4 seconds. After the last failed attempt, the message lands in the failure transport (dead letter queue), where it can be manually inspected and resent.
To distinguish between transient errors (a network timeout that will repeat) and permanent errors (an invalid payload that will always fail), you use the UnrecoverableMessageHandlingException class. If the handler throws this exception, Messenger does not retry the message but moves it straight to the failure transport. This prevents a permanently invalid webhook payload from clogging the worker queue with futile retry attempts.
Monitoring webhook processing: Symfony Messenger provides the command bin/console messenger:failed:show, which lists all failed messages in the failure transport, and messenger:failed:retry for manual retries. For production environments, a Laravel Horizon-like monitoring approach with Sentry or a custom dashboard that queries the Messenger status via database statistics is recommended. The messenger_messages table contains all pending and failed messages with timestamps.
8. Idempotency: safely catching duplicate webhooks
External services often guarantee "at-least-once" delivery of their webhooks: the same event can arrive multiple times if the first response is lost or the external system detects a timeout and retries the webhook. Without an idempotency check, the same order gets marked as paid twice, the same newsletter subscriber gets added twice, or the same customer account gets created twice. This is the second most common webhook problem after missing signature verification.
The solution: every webhook event from a reputable service carries a unique event ID. Stripe events have an id like evt_1234..., GitHub events have an X-GitHub-Delivery header. This ID is stored in an idempotency table on the first processing attempt. On repeated arrival, the consumer checks whether the ID has already been processed and returns immediately without re-running the business logic. This check must be atomic, a unique index on the idempotency table prevents race conditions in concurrent processing attempts.
In the Symfony Messenger context, the idempotency check can be implemented as middleware: an IdempotencyMiddleware checks the event ID against the database before every handler call. If it already exists, the message is marked as successful (no error, no retry). If it is new, the ID is stored and the handler is invoked. If the handler fails after the ID was stored, the ID is removed again so that the retry can run the business logic again.
9. Comparison: webhook processing strategies
There are several approaches for Symfony webhooks, ranging from simple synchronous processing to full Messenger integration. The choice depends on webhook volume, processing duration and reliability requirements.
| Strategy | Advantage | Disadvantage | Use case |
|---|---|---|---|
| Synchronous in the controller | No setup needed | Timeout risk, no retry | Only for very fast operations |
| Messenger + Database | Retry, monitoring, persistence | Database dependency | Recommended for most projects |
| Messenger + Redis | Very fast, horizontally scalable | Messages get lost on crash | High volumes, tolerant systems |
| Messenger + AMQP/SQS | Guaranteed delivery, dead letter | Setup effort, external dependency | Enterprise, critical events |
| Without signature verification | None | Security vulnerability | Never in production |
The recommended Symfony webhook architecture for most production projects: Messenger with the Doctrine transport for reliability and easy monitoring. The Doctrine transport stores all messages in the database, survives restarts and allows manual inspection via SQL queries or the messenger:failed:show command. For very high webhook volumes (over 1,000 events per minute), an external broker such as RabbitMQ or AWS SQS with the AMQP transport is a better fit.
Mironsoft
Symfony integration, webhook architectures and asynchronous processing
Need to integrate webhooks safely and reliably into Symfony?
We build secure webhook integrations for Symfony projects, from HMAC signature verification through type-safe event DTOs to asynchronous Messenger processing with retry and idempotency guarantees.
Webhook security
HMAC signature verification, replay protection and idempotency for Stripe, GitHub and custom services
Async processing
Symfony Messenger with retry strategy, dead letter queue and monitoring for reliable webhook processing
Type-safe events
RemoteEvent DTOs for all relevant service events with full type safety and testability
10. Summary
Processing incoming Symfony webhooks robustly means: HMAC signature verification for every request, type-safe event DTOs instead of raw array access, an immediate 200 response with asynchronous processing via Symfony Messenger, and idempotency checks against duplicate processing. The Symfony Webhook component provides the necessary infrastructure, and custom RequestParser classes and RemoteEventConsumer classes add the project-specific logic. The combination of HMAC, Messenger retry and idempotency makes webhook processing production-ready.
The architecture pays off especially as webhook volume grows: new event types are added as new DTO classes, new services as new parser classes. The webhook endpoint itself stays minimal and does not need to be touched when extending it. Testing is possible at every level: unit tests for parsers and consumers, integration tests with mocked HTTP requests and real event objects.
Symfony Webhooks: The Essentials at a Glance
Signature verification
HMAC-SHA256 in the RequestParser with hash_equals() for a constant-time comparison. Use the raw body for the computation, not the parsed JSON array.
Type-safe events
RemoteEvent DTOs with a fromPayload() factory instead of direct array access. Missing required fields throw RejectWebhookException for clear 400 responses.
Async with Messenger
Respond with 200 immediately, process via the queue. Configure a retry strategy, use UnrecoverableException for permanently invalid payloads.
Idempotency
Store the event ID in an idempotency table. A unique index prevents race conditions. Return success immediately on repeated arrival.