Clean Payment Integration
Integrating Stripe into Symfony is more than dropping in an API key. Webhooks must be processed with signature verification, idempotency prevents double bookings after network errors, and order fulfillment logic must only run after a confirmed payment, never before the checkout.
Table of Contents
- 1. Why a Stripe integration demands care
- 2. Stripe SDK and Symfony configuration
- 3. Creating a Stripe Checkout Session
- 4. Processing webhooks securely
- 5. Idempotency: preventing double bookings
- 6. Order fulfillment after confirmed payment
- 7. Handling refunds correctly
- 8. Error cases and monitoring
- 9. Stripe vs. other payment providers in Symfony
- 10. Summary
- 11. FAQ
1. Why a Stripe integration demands care
A Stripe integration in Symfony looks simple at first glance: configure the API key, create a Checkout Session, redirect. The real complexity only shows up in production. Network errors between Stripe and the Symfony backend can cause a webhook to arrive twice or not at all. If the order fulfillment logic runs inside the checkout controller instead of after a confirmed webhook, orders can end up without a completed payment. And if a webhook endpoint performs no signature verification, it becomes an open entry point for forged payment notifications.
The basic rule for a correct Stripe integration in Symfony: Stripe is the single source of truth for payment status. The Symfony application reacts to Stripe webhooks instead of making its own assumptions about payment state. Webhooks can arrive delayed, be delivered multiple times, or arrive out of the expected order. Every webhook handler must be idempotent: a second call with the same event must not produce duplicate side effects. This discipline is what separates a stable Stripe integration from one that produces inconsistent data under load or after network problems.
2. Stripe SDK and Symfony configuration
Installing the official Stripe PHP SDK is done via Composer. The SDK has no external dependencies and integrates seamlessly into Symfony services. The secret key and the webhook signing secret are configured as environment variables and never written directly into configuration files. Symfony's secret management via symfony console secrets:set encrypts these values for production environments. In local development you use Stripe's test mode with sk_test_ keys and the Stripe CLI for local webhook forwarding.
A dedicated Stripe service in Symfony wraps all API calls to Stripe. The class has a readonly constructor with the initialized \Stripe\StripeClient. Direct calls to \Stripe\Stripe::setApiKey() inside controllers or anywhere else in the code are an anti-pattern: they make testing difficult and spread Stripe configuration across the whole project. The service is registered in the Symfony container and injected via constructor injection into every controller and message handler that needs payment functionality.
<?php
declare(strict_types=1);
namespace App\Service;
use Stripe\Checkout\Session;
use Stripe\Event;
use Stripe\Exception\ApiErrorException;
use Stripe\Exception\SignatureVerificationException;
use Stripe\StripeClient;
use Stripe\Webhook;
/**
* Centralised Stripe service, all Stripe API calls go through here.
*/
final readonly class StripeService
{
private StripeClient $stripe;
public function __construct(
private string $secretKey,
private string $webhookSecret,
private string $successUrl,
private string $cancelUrl,
) {
// StripeClient is initialised once, no global API key setting
$this->stripe = new StripeClient($this->secretKey);
}
/**
* Create a Stripe Checkout Session for a given order.
*
* @param array<array{price_data: array{currency: string, product_data: array{name: string}, unit_amount: int}, quantity: int}> $lineItems
*/
public function createCheckoutSession(string $orderId, array $lineItems): Session
{
return $this->stripe->checkout->sessions->create([
'payment_method_types' => ['card'],
'line_items' => $lineItems,
'mode' => 'payment',
'success_url' => $this->successUrl . '?session_id={CHECKOUT_SESSION_ID}',
'cancel_url' => $this->cancelUrl,
// Attach order ID to the session for webhook processing
'metadata' => ['order_id' => $orderId],
]);
}
/**
* Verify webhook signature and construct the event.
*
* @throws SignatureVerificationException if the signature is invalid
*/
public function constructWebhookEvent(string $payload, string $sigHeader): Event
{
// Always verify the signature, never trust unverified webhook payloads
return Webhook::constructEvent($payload, $sigHeader, $this->webhookSecret);
}
}
3. Creating a Stripe Checkout Session
The Stripe Checkout Session is the recommended entry point for payments in Symfony applications. Instead of building your own credit card form, the user is redirected to Stripe's hosted checkout page. This eliminates PCI compliance requirements for the Symfony application, because no card data ever touches the server. The Checkout Session is created inside the Symfony controller and contains all the product information, prices, and metadata needed for the later webhook processing.
The metadata fields of the Checkout Session are essential: this is where you record the internal order ID or user ID. When the Stripe webhook arrives after a successful payment, this metadata is available on the event object, so the Symfony application can match the order to the correct record without external state lookups. The Checkout Session ID is additionally stored in the order table so the Stripe checkout can be traced directly if questions come up. After creating the session, the controller redirects straight to the session's url property.
4. Processing webhooks securely
Stripe webhooks are HTTP POST requests that Stripe sends to a configured endpoint in the Symfony application whenever a payment event occurs. The most important event for e-commerce applications is checkout.session.completed, it signals that a payment has been successfully completed. Signature verification with Webhook::constructEvent() is not optional: without it, any arbitrary HTTP request to the endpoint can send forged payment confirmations.
The webhook controller in Symfony reads the raw request body ($request->getContent()) and the Stripe-Signature header. These two values are passed to the Stripe service, which verifies the signature and constructs the event object. If the signature is invalid, the SDK throws a SignatureVerificationException, and the controller returns HTTP 400. If the signature is valid, the event type is checked and forwarded to a dedicated handler. Heavy processing logic, sending emails, adjusting stock levels, belongs in an asynchronous Symfony message handler, not in the synchronous webhook request lifecycle.
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Message\ProcessStripePaymentMessage;
use App\Service\StripeService;
use Stripe\Exception\SignatureVerificationException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Attribute\Route;
/**
* Handles incoming Stripe webhook events.
*/
#[Route('/webhook/stripe', name: 'stripe_webhook', methods: ['POST'])]
final class StripeWebhookController extends AbstractController
{
public function __construct(
private readonly StripeService $stripeService,
private readonly MessageBusInterface $messageBus,
) {}
public function __invoke(Request $request): Response
{
$payload = $request->getContent();
$sigHeader = $request->headers->get('Stripe-Signature', '');
try {
// Signature verification, rejects all unverified payloads
$event = $this->stripeService->constructWebhookEvent($payload, $sigHeader);
} catch (SignatureVerificationException) {
// Return 400, Stripe will retry; log for monitoring
return new Response('Invalid signature', Response::HTTP_BAD_REQUEST);
}
// Dispatch to async handler, webhook response must be fast (< 30 seconds)
match ($event->type) {
'checkout.session.completed' => $this->messageBus->dispatch(
new ProcessStripePaymentMessage(
sessionId: $event->data->object->id,
orderId: $event->data->object->metadata->order_id,
amountTotal: $event->data->object->amount_total,
)
),
// Log unhandled event types for observability
default => null,
};
// Always return 200, Stripe stops retrying on 200
return new Response('OK', Response::HTTP_OK);
}
}
5. Idempotency: preventing double bookings
Stripe guarantees at-least-once webhook delivery, not exactly-once. That means the same checkout.session.completed event can be sent to the Symfony application two or three times, whether because of network errors or because the application did not return an HTTP 200 status on the first attempt. Without idempotency protection, this leads to duplicate orders, duplicate emails, and duplicate stock deductions. The solution in Symfony: store every processed Stripe event ID in the database and check before processing whether the event has already been handled.
The implementation uses a stripe_events database table with the event ID as a unique constraint. When a webhook arrives, the message handler checks whether the ID already exists. If it does, processing is silently skipped and HTTP 200 is returned, so Stripe stops retrying. If it does not, the ID is atomically inserted into the table and processing begins. This insert happens in the same database transaction as the order processing, so no intermediate state is possible. Stripe itself also offers idempotency keys for API calls: when creating a Checkout Session you can pass your own key, which Stripe uses to detect duplicate requests.
6. Order fulfillment after confirmed payment
The most common architectural mistake in Stripe integrations in Symfony: the order is created in the checkout controller before the user is even redirected to the Stripe page. If the payment then fails or is aborted, an order without a payment remains in the database. The correct order is the reverse: the controller only creates a preliminary order with status pending and the Checkout Session ID. The actual order fulfillment, reducing stock, scheduling delivery, sending the confirmation email, only happens in the webhook handler, after a confirmed payment from Stripe.
The Symfony message handler for ProcessStripePaymentMessage changes the order status from pending to paid, triggers all downstream processes, and writes the Stripe payment intent ID into the order for later refund workflows. Because this handler runs asynchronously via Symfony Messenger, the webhook response is done with HTTP 200 right away, the heavy work happens outside the HTTP request lifecycle. Transactional emails are sent via Symfony Mailer, and if the handler fails with an exception, the message lands in the failed queue and can be manually reprocessed.
7. Handling refunds correctly
Refunds in a Stripe-Symfony integration follow the same pattern as payments: the action itself, creating the refund at Stripe, happens through the Stripe API. The consequences for the Symfony application, updating the order status, reversing the stock deduction, triggering an accounting event, happen through a webhook. The relevant event is charge.refunded or refund.created. Partial refunds are possible: Stripe provides the refunded amount in the event, which the Symfony application reconciles against the original payment amount.
The Symfony controller for refunds first checks whether the order belongs to the logged-in user and whether it is in a refundable status. It then calls the Stripe service, which creates the refund via the API. The payment intent ID stored during the webhook processing of the original payment is the reference for the refund. Stripe sends a webhook after the refund confirming the final status. Only then is the order status in Symfony set to refunded and the confirmation email sent. Never set the status directly after the API call, only after webhook confirmation.
<?php
declare(strict_types=1);
namespace App\MessageHandler;
use App\Entity\Order;
use App\Message\ProcessStripePaymentMessage;
use App\Repository\OrderRepository;
use App\Repository\StripeEventRepository;
use App\Service\OrderEmailService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
/**
* Processes confirmed Stripe payments asynchronously.
* Idempotent: duplicate events are silently ignored.
*/
#[AsMessageHandler]
final class ProcessStripePaymentHandler
{
public function __construct(
private readonly OrderRepository $orderRepository,
private readonly StripeEventRepository $stripeEventRepository,
private readonly EntityManagerInterface $entityManager,
private readonly OrderEmailService $emailService,
) {}
public function __invoke(ProcessStripePaymentMessage $message): void
{
// Idempotency check, skip if already processed
if ($this->stripeEventRepository->isProcessed($message->sessionId)) {
return;
}
$order = $this->orderRepository->findByPendingCheckoutSession($message->orderId);
if (!$order || $order->getStatus() !== Order::STATUS_PENDING) {
// Order already processed or not found, mark as seen and skip
$this->stripeEventRepository->markProcessed($message->sessionId);
return;
}
$this->entityManager->wrapInTransaction(function () use ($order, $message): void {
// Mark event as processed within the same transaction, prevents race conditions
$this->stripeEventRepository->markProcessed($message->sessionId);
$order->markAsPaid($message->sessionId, $message->amountTotal);
$this->entityManager->flush();
});
// Email is sent after successful DB commit, outside the transaction
$this->emailService->sendOrderConfirmation($order);
}
}
8. Error cases and monitoring
A production-grade Stripe integration in Symfony must handle error cases systematically and be observable. The most common error cases: Stripe API unreachable when creating the Checkout Session, invalid webhook signature due to a misconfigured key, a message handler throwing an exception and landing in the failed queue, or the user abandoning the checkout. Each of these needs a defined response in the Symfony code and a monitoring entry.
The Stripe dashboard provides built-in webhook monitoring: you can see which events were delivered, which returned an error, and which Stripe is currently retrying. On the Symfony side, the webhook controller writes a structured log entry via Monolog on failed signature verification, including the remote IP and a truncated header. Symfony Messenger's failed queue makes failed message handler calls visible and enables manual retries. A health check endpoint verifies that the Stripe API is reachable by making a test call against stripe.balance.retrieve(), ideal for Kubernetes readiness probes.
9. Stripe vs. other payment providers in Symfony
The choice of payment provider affects the depth of integration in Symfony. Stripe is the first choice for many projects, but not the only option.
| Criterion | Stripe | PayPal | Mollie |
|---|---|---|---|
| PHP SDK quality | Excellent, type-safe | Good, but older | Very good |
| Webhook reliability | High, with retry and dashboard | Medium | High |
| Local testing | Stripe CLI for webhooks | Sandbox, no CLI | Test mode, no CLI |
| SEPA direct debit (Germany) | Supported | Limited | Strong (NL provider) |
| Symfony bundle available | No official bundle | Unofficial | mollie/mollie-api-php |
For most Symfony projects with an international focus, Stripe is the best choice: the PHP SDK is type-safe and well maintained, the developer experience with the Stripe CLI for local webhooks is unmatched, and the dashboard offers excellent monitoring. Mollie is the better choice when SEPA direct debit and Dutch or Belgian payment methods like iDEAL matter. PayPal is often offered alongside another provider, rarely as the only payment method, because integration in Symfony is more complex and webhook reliability is lower.
Mironsoft
Symfony payment integration, Stripe, and secure payment infrastructure
Need Stripe integrated securely in Symfony?
We implement Stripe integrations in Symfony with webhook signature verification, idempotency protection, asynchronous order processing, and complete monitoring for your payment stack.
Checkout & webhooks
Stripe Checkout Session, webhook endpoint with signature verification, and asynchronous processing
Idempotency
Preventing duplicate processing of webhooks and atomic transactions for payment logic
Monitoring & testing
Stripe CLI for local webhooks, PHPUnit tests with a mocked SDK, and production monitoring
10. Summary
A clean Stripe integration in Symfony follows a clear pattern: create a Checkout Session and redirect the user, receive the webhook with signature verification, process the event asynchronously via Symfony Messenger, and only run the order fulfillment logic after a confirmed payment. The idempotency check via stored event IDs prevents duplicate processing on repeated webhook deliveries. Refunds run through the Stripe API and are confirmed by the charge.refunded webhook, never directly after the API call.
The biggest security gain comes from consistently verifying the signature of every incoming webhook. Without this check, the endpoint is an open security risk. With it, and with idempotency protection, the Stripe-Symfony integration stays stable and predictable under load, after network errors, and during Stripe retries. The Stripe CLI makes local development and testing as easy as working against a real Stripe integration, without public domains or ngrok tunnels.
Symfony + Stripe, the essentials at a glance
Webhook security
Webhook::constructEvent() verifies the signature. Without this check, the endpoint is open to forged payment confirmations. Webhook secret as an environment variable.
Idempotency
Store processed event IDs in the DB. Silently skip duplicate events. Insert and order processing in one atomic transaction.
Order fulfillment
Only set the order to paid after webhook confirmation. Asynchronous via Symfony Messenger, no heavy code in the webhook request.
Local testing
Stripe CLI forwards webhooks locally: stripe listen --forward-to localhost:8000/webhook/stripe. Use test mode with sk_test_ keys.