Symfony + Stripe: Clean Payment Integration
AI generated
SF
{ }
Symfony · Stripe · Payments · Webhooks · PHP
Symfony + Stripe:
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.

18 min read Checkout Session · Webhooks · Idempotency · Refunds · Testing Symfony 7.x · Stripe PHP SDK 13.x · PHP 8.4

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.

11. FAQ: Symfony + Stripe Payment Integration

1Why webhook signature verification?
Without it, anyone can send forged payment confirmations. Webhook::constructEvent() validates the Stripe-Signature header against the webhook secret.
2What is webhook idempotency?
Stripe delivers webhooks at least once. Store processed event IDs in the DB and silently skip on the second call, preventing duplicate orders.
3When to run order fulfillment logic?
Only after the checkout.session.completed webhook. The order stays pending until then. Never before the Stripe checkout.
4Testing Stripe webhooks locally?
Stripe CLI: stripe listen --forward-to localhost:8000/webhook/stripe. Forwards real test events to the local server, including a correct signature.
5Handling refunds correctly?
Create the refund via the Stripe API. Set the order status only after the charge.refunded webhook. Store the payment intent ID on the original order.
6Why Symfony Messenger for webhooks?
Stripe expects a response within 30 seconds. Messenger dispatches heavy work asynchronously, HTTP 200 immediately, email and stock afterward.
7Storing the Stripe secret key securely?
As an environment variable STRIPE_SECRET_KEY. Symfony Secrets for production. Locally in .env.local, which does not go into the repo.
8What happens during server downtime?
Stripe retries with exponential backoff over several hours. Idempotency check prevents duplicate processing once the server is back up.
9Which Stripe events to handle at minimum?
checkout.session.completed, charge.refunded, payment_intent.payment_failed. Log all other events for later analysis.
10Mocking the Stripe service in PHPUnit tests?
Abstract StripeService behind an interface. Replace it with a mock in tests. Inject StripeClient via DI, never set it globally. Use the Stripe CLI for integration tests.