Testing Stripe direct debit webhooks with PHP and Symfony service tags
Use Symfony service tags and the Stripe CLI to test asynchronous BECS and SEPA payment events, verify webhook signatures and handle pending payments correctly.
A customer completes a direct debit form, the browser returns to your application and the payment is still processing. That is a normal payment state, not an integration failure.
For an Australian product accepting BECS Direct Debit, the gap between initiating a payment and learning its outcome can last several business days. If the product also accepts SEPA payments, the same asynchronous design applies. I would make that waiting period visible in the application instead of pretending the checkout redirect means the money has arrived.
This walkthrough uses PHP 8.3 and Symfony 6.4 conventions available in late 2024. The pattern is a verified webhook endpoint, a tagged collection of PHP handlers and the Stripe CLI forwarding events into a local application. The examples log payment observations; they deliberately stop short of implementing your billing ledger or fulfilling orders.
Several days of processing is a real application state
BECS Direct Debit debits Australian bank accounts in AUD. SEPA Direct Debit uses EUR and accounts in the Single Euro Payments Area; it is not the Australian bank-debit scheme. Availability also depends on the Stripe account and payment method configuration. Stripe’s Australian BECS guide and SEPA guide.
Think in business days, including submission cutoffs, rather than a fixed seven-day countdown. Stripe describes BECS notification as taking up to three business days, while its SEPA guidance describes a longer processing window. A debit can therefore remain pending around a week or longer in calendar time. Check the timing applicable to your account; success notification, funds availability and a payout to your bank are different milestones. The linked payment-method documentation covers those distinctions.
My rule is simple: never mark an order paid because seven days have elapsed. Use the payment outcome, and reconcile anything that remains unexpectedly pending. A successful debit also does not eliminate later dispute risk.
Choose events that describe the payment outcome
For this PaymentIntent-based example, I would start with three events:
| Stripe event | Application meaning |
|---|---|
payment_intent.processing | The debit is underway; show a pending payment state |
payment_intent.succeeded | The payment succeeded; evaluate the product’s fulfilment policy |
payment_intent.payment_failed | A payment attempt failed; offer an appropriate recovery path |
These events carry a PaymentIntent in data.object. A failed attempt can leave that intent available for another payment method; do not equate it with a permanently failed order. Stripe event definitions.
If you use Checkout, completing a session is not sufficient evidence that a delayed payment succeeded. Design around the asynchronous outcome events appropriate to that integration. Similarly, a successful SetupIntent establishes payment-method setup, not a collected payment. Keep these concepts separate from the start.
Give each webhook handler a small contract
In an existing Symfony application, install the Stripe PHP SDK and logging integration if they are not already present:
composer require stripe/stripe-php symfony/monolog-bundle
Keep the chosen package versions in your lockfile and align the SDK with the Stripe API version used by your webhook endpoint.
Create src/Webhook/StripeWebhookHandler.php:
<?php
namespace App\Webhook;
use Stripe\Event;
interface StripeWebhookHandler
{
public function supports(Event $event): bool;
public function handle(Event $event): void;
}
The interface lets a payment observer, an alert handler or another capability join the same pipeline. I prefer this to a controller that grows another branch every time the billing integration needs a new behaviour.
In config/services.yaml, keep the application's existing service discovery and defaults, and add this rule under services:
services:
_instanceof:
App\Webhook\StripeWebhookHandler:
tags: ['app.stripe_webhook_handler']
With the usual App\ service discovery, implementations receive the tag automatically. Tags group services inside Symfony; they do not subscribe your application to Stripe events. The Stripe endpoint configuration or CLI listener controls delivery. Symfony 6.4 service tags.
Observe the asynchronous lifecycle
Create src/Webhook/PaymentIntentObserver.php:
<?php
namespace App\Webhook;
use Psr\Log\LoggerInterface;
use Stripe\Event;
use Stripe\PaymentIntent;
final class PaymentIntentObserver implements StripeWebhookHandler
{
private const OBSERVATIONS = [
'payment_intent.processing' => 'pending',
'payment_intent.succeeded' => 'paid',
'payment_intent.payment_failed' => 'attempt_failed',
];
public function __construct(private LoggerInterface $logger)
{
}
public function supports(Event $event): bool
{
return isset(self::OBSERVATIONS[$event->type]);
}
public function handle(Event $event): void
{
$intent = $event->data->object;
if (!$intent instanceof PaymentIntent) {
throw new \UnexpectedValueException('Expected a PaymentIntent.');
}
$this->logger->info('Direct debit lifecycle observed', [
'event_id' => $event->id,
'event_type' => $event->type,
'payment_intent_id' => $intent->id,
'observation' => self::OBSERVATIONS[$event->type],
'stripe_status' => $intent->status,
'payment_method_types' => $intent->payment_method_types,
]);
}
}
The observer labels the event for a local test. It does not store a paid state, send a receipt or grant access. The same event types can describe card payments, so inspect the payment method in your actual integration before making direct-debit-specific decisions.
I would avoid logging bank account details or the complete webhook payload. Event and PaymentIntent IDs are enough to connect the application log to the test payment in Stripe.
Verify the signature before dispatching
Create src/Controller/StripeWebhookController.php:
<?php
namespace App\Controller;
use App\Webhook\StripeWebhookHandler;
use Stripe\Exception\SignatureVerificationException;
use Stripe\Webhook;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\DependencyInjection\Attribute\TaggedIterator;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class StripeWebhookController
{
/** @param iterable<StripeWebhookHandler> $handlers */
public function __construct(
#[Autowire('%env(STRIPE_WEBHOOK_SECRET)%')]
private string $secret,
#[TaggedIterator('app.stripe_webhook_handler')]
private iterable $handlers,
) {
}
#[Route('/webhooks/stripe', name: 'stripe_webhook', methods: ['POST'])]
public function __invoke(Request $request): Response
{
try {
$event = Webhook::constructEvent(
$request->getContent(),
$request->headers->get('Stripe-Signature', ''),
$this->secret,
);
} catch (SignatureVerificationException | \UnexpectedValueException) {
return new Response('Invalid webhook', Response::HTTP_BAD_REQUEST);
}
foreach ($this->handlers as $handler) {
if ($handler->supports($event)) {
$handler->handle($event);
}
}
return new Response('', Response::HTTP_NO_CONTENT);
}
}
Use the original request bytes for signature verification, before decoding or altering the body. This endpoint should be reachable without an interactive user login, while retaining signature verification as its authentication boundary. Keep it outside browser-form CSRF handling. Exceptions raised by handlers are deliberately not converted into success responses. Stripe webhook verification and delivery guidance.
The tagged iterator supplies every handler with the tag. Here, all matching handlers run; do not accidentally register two fulfilment handlers for the same business effect.
Forward Stripe events to Symfony locally
Install the Stripe CLI, sign in to the intended test account and run Symfony on a known port. For example, with the Symfony CLI installed:
symfony server:start --port=8000 --no-tls
In another terminal:
stripe login
stripe listen \
--events payment_intent.processing,payment_intent.succeeded,payment_intent.payment_failed \
--forward-to http://127.0.0.1:8000/webhooks/stripe
Copy the whsec_... secret printed by this listener into the application's uncommitted .env.local:
STRIPE_WEBHOOK_SECRET=whsec_replace_with_the_cli_listener_secret
The CLI listener secret and a deployed endpoint's signing secret are different values. Use the listener’s secret for local forwarding and the deployed endpoint’s secret in that environment. Check that the application loads the new value. Stripe CLI listen reference.
If Symfony Security protects all routes, put a rule for ^/webhooks/stripe$ with roles: PUBLIC_ACCESS before broader login rules in security.yaml. This makes the route reachable; it does not bypass the controller’s signature check.
Test dispatch first, then test a real debit lifecycle
For a quick check of the endpoint and tagged handler:
stripe trigger payment_intent.succeeded
stripe trigger payment_intent.payment_failed
Watch the CLI for a 204 response and var/log/dev.log for the event type, PaymentIntent ID and observation. These commands generate test fixtures, typically involving card payments. They test dispatch and signature verification, not a complete BECS or SEPA bank-debit lifecycle. Supported triggers depend on your CLI version; inspect stripe trigger --help before assuming a payment_intent.processing fixture exists. Stripe CLI trigger reference.
To exercise direct debit properly, leave the listener running and initiate a payment through your actual test-mode checkout or PaymentIntent integration. Enable the intended method, collect the required mandate and use Stripe's documented test bank details. For Australian BECS, Stripe provides BSB 000000 and account 000123456 for its successful test flow. For SEPA, use the test IBANs and failure cases in the SEPA testing documentation; create an EUR payment rather than reusing an AUD example.
Check the same PaymentIntent through processing and its later outcome. Also exercise the documented failure cases. Test-mode behaviour is simulated and does not require waiting through a live bank’s full clearing period. Independently triggered CLI fixtures normally create different PaymentIntents, so two successful trigger commands do not prove one payment transitioned correctly.
Turn observations into durable business behaviour
The logging example is intentionally small. Before it controls paid access or order fulfilment, I would add a durable processing boundary:
- Record each Stripe event ID with a database uniqueness constraint and apply the related state change in the same transaction. A duplicate delivery should not repeat the business effect.
- Link payments to records created by your backend. Verify the expected amount, currency, account context and environment before granting access.
- Handle out-of-order delivery. A late processing event must not overwrite a successful payment. Reconcile with Stripe’s current object when needed, and serialize updates for the same PaymentIntent.
- Put slow work behind a durable queue. Return success only after persisting the work, then make the consumer safe to retry. Use an outbox or another deliberate mechanism for emails and other side effects.
Stripe documents retries, duplicate delivery and non-guaranteed event ordering. Webhook delivery behaviour.
For subscriptions, reconcile invoice and subscription events according to the billing policy rather than treating every successful PaymentIntent as a subscription entitlement. Keep dispute handling separate too: a successful direct debit can still be disputed later.
My preference is to make pending payments boring. The customer sees that the debit is processing, support can find the relevant payment, and eventual success or failure updates the product through the same reviewable path. Symfony service tags keep that path extensible; the webhook and persistence design make it trustworthy.
If your team needs help with PHP, Symfony or payment integrations, tell me about your application.