SF
{ }
Symfony · Stripe · Zahlungen · Webhooks · PHP
Symfony + Stripe:
Zahlungen sauber integrieren

Stripe in Symfony zu integrieren ist mehr als einen API-Key einzutragen. Webhooks müssen signaturgeprüft verarbeitet werden, Idempotenz verhindert Doppelbuchungen bei Netzwerkfehlern, und die Auftragslogik darf erst nach bestätigter Zahlung ausgeführt werden – nicht vor dem Checkout.

18 Min. Lesezeit Checkout Session · Webhooks · Idempotenz · Refunds · Testing Symfony 7.x · Stripe PHP SDK 13.x · PHP 8.4

1. Warum Stripe-Integration Sorgfalt erfordert

Eine Stripe-Integration in Symfony ist auf den ersten Blick simpel: API-Key konfigurieren, Checkout-Session erstellen, Redirect. Die echten Komplexitäten zeigen sich erst im Betrieb. Netzwerkfehler zwischen Stripe und dem Symfony-Backend können dazu führen, dass ein Webhook zweimal oder gar nicht ankommt. Wenn die Auftragslogik im Checkout-Controller ausgeführt wird statt nach bestätigtem Webhook, entstehen Bestellungen ohne abgeschlossene Zahlung. Und wenn ein Webhook-Endpunkt keine Signaturprüfung durchführt, ist er ein offener Einsprungpunkt für gefälschte Zahlungsbenachrichtigungen.

Die Grundregel für eine korrekte Stripe-Integration in Symfony: Stripe ist der einzige Wahrheitsgeber über den Zahlungsstatus. Die Symfony-Anwendung reagiert auf Stripe-Webhooks, statt selbst Annahmen über den Zahlungsstatus zu treffen. Webhooks können mit Verzögerung ankommen, mehrfach ausgeliefert werden oder in einer anderen Reihenfolge als erwartet eintreffen. Jede Webhook-Verarbeitung muss idempotent sein: Ein zweiter Aufruf mit demselben Event darf keine doppelten Nebeneffekte erzeugen. Diese Sorgfalt trennt eine stabile Stripe-Integration von einer, die unter Last oder nach Netzwerkproblemen inkonsistente Datenzustände produziert.

2. Stripe SDK und Symfony-Konfiguration

Die Installation des offiziellen Stripe PHP SDK erfolgt via Composer. Das SDK ist ohne externe Abhängigkeiten und integriert sich nahtlos in Symfony-Services. Der Secret Key und der Webhook-Signing-Secret werden als Umgebungsvariablen konfiguriert und nie direkt in Konfigurationsdateien geschrieben. Symfony's Secret-Management via symfony console secrets:set verschlüsselt diese Werte für Produktionsumgebungen. Im lokalen Development nutzt man Stripes Testmodus mit sk_test_-Keys und Stripe CLI für lokale Webhook-Weiterleitung.

Ein dedizierter Stripe-Service in Symfony kapselt alle API-Aufrufe an Stripe. Die Klasse hat einen readonly-Constructor mit dem initialisierten \Stripe\StripeClient. Direkte Aufrufe an \Stripe\Stripe::setApiKey() in Controllern oder anderswo im Code sind ein Anti-Pattern: Sie machen Tests schwierig und verteilen Stripe-Konfiguration über das gesamte Projekt. Der Service wird im Symfony-Container registriert und über Constructor-Injection in alle Controller und Message-Handler injiziert, die Zahlungsfunktionen benötigen.


<?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. Stripe Checkout Session erstellen

Die Stripe Checkout Session ist der empfohlene Einstiegspunkt für Zahlungen in Symfony-Anwendungen. Statt ein eigenes Formular für Kreditkartendaten zu bauen, wird der Nutzer zu Stripes gehosteter Checkout-Seite weitergeleitet. Das eliminiert PCI-Compliance-Anforderungen für die Symfony-Anwendung, weil keine Karteninformationen den Server berühren. Die Checkout-Session wird im Symfony-Controller erstellt und enthält alle Produktinformationen, Preise und Metadaten, die für die spätere Webhook-Verarbeitung nötig sind.

Die metadata-Felder der Checkout-Session sind entscheidend: Hier trägt man die interne Bestell-ID oder Nutzer-ID ein. Wenn der Stripe-Webhook nach erfolgter Zahlung eintrifft, sind diese Metadaten im Event-Objekt verfügbar — so kann die Symfony-Anwendung die Bestellung dem richtigen Datensatz zuordnen, ohne externe Zustands-Lookups. Die Checkout-Session-ID wird zusätzlich in der Bestelltabelle gespeichert, um bei Rückfragen den Stripe-Checkout direkt zuordnen zu können. Nach dem Erstellen der Session leitet der Controller direkt zur url-Eigenschaft der Session weiter.

4. Webhooks sicher verarbeiten

Stripe-Webhooks sind HTTP-POST-Requests, die Stripe an einen konfigurierten Endpunkt in der Symfony-Anwendung sendet, wenn ein Zahlungsereignis eintritt. Der wichtigste Event für E-Commerce-Anwendungen ist checkout.session.completed — er signalisiert, dass eine Zahlung erfolgreich abgeschlossen wurde. Die Signaturprüfung mit Webhook::constructEvent() ist nicht optional: Ohne Prüfung kann jeder beliebige HTTP-Request an den Endpunkt gefälschte Zahlungsbestätigungen senden.

Der Webhook-Controller in Symfony liest den rohen Request-Body ($request->getContent()) und den Stripe-Signature-Header. Diese beiden Werte werden an den Stripe-Service übergeben, der die Signatur prüft und das Event-Objekt konstruiert. Wenn die Signatur ungültig ist, wirft das SDK eine SignatureVerificationException, und der Controller gibt HTTP 400 zurück. Bei gültiger Signatur wird der Event-Typ geprüft und an einen dedizierten Handler weitergeleitet. Schwere Verarbeitungslogik — E-Mail-Versand, Lagerbestandsanpassung — gehört in einen asynchronen Symfony-Message-Handler, nicht in den synchronen 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. Idempotenz: Doppelbuchungen verhindern

Stripe garantiert mindestens-einmalige Webhook-Zustellung, nicht genau-einmalige. Das bedeutet: Derselbe checkout.session.completed-Event kann zwei- oder dreimal an die Symfony-Anwendung gesendet werden — bei Netzwerkfehlern oder wenn die Anwendung beim ersten Versuch keinen HTTP-200-Status zurückgab. Ohne Idempotenz-Schutz führt das zu doppelten Bestellungen, doppelten E-Mails und doppelten Lagerbestand-Abzügen. Die Lösung in Symfony: Jede verarbeitete Stripe-Event-ID in der Datenbank speichern und vor der Verarbeitung prüfen, ob der Event bereits abgearbeitet wurde.

Die Implementierung nutzt eine stripe_events-Datenbanktabelle mit der Event-ID als Unique-Constraint. Beim Eingang eines Webhooks prüft der Message-Handler, ob die ID bereits existiert. Wenn ja, wird die Verarbeitung still übersprungen und HTTP 200 zurückgegeben — Stripe soll aufhören zu retrien. Wenn nein, wird die ID atomar in die Tabelle eingetragen und die Verarbeitung beginnt. Der Eintrag geschieht in derselben Datenbanktransaktion wie die Auftragsverarbeitung, sodass kein Zwischenzustand entsteht. Stripe selbst bietet ebenfalls Idempotency Keys für API-Aufrufe an: Beim Erstellen einer Checkout-Session übergibt man einen eigenen Key, den Stripe nutzt, um doppelte Requests zu erkennen.

6. Auftragslogik nach bestätigter Zahlung

Die häufigste Architektur-Fehlentscheidung bei Stripe-Integrationen in Symfony: Die Bestellung wird im Checkout-Controller erstellt, noch bevor der Nutzer zur Stripe-Seite weitergeleitet wird. Wenn die Zahlung dann scheitert oder abgebrochen wird, bleibt eine Bestellung ohne Zahlung in der Datenbank. Die korrekte Reihenfolge ist umgekehrt: Der Controller erstellt nur eine vorläufige Bestellung mit Status pending und die Checkout-Session-ID. Die eigentliche Auftragsverarbeitung — Lagerbestand reduzieren, Lieferung einplanen, Bestätigungs-E-Mail senden — findet erst im Webhook-Handler statt, nach bestätigter Zahlung durch Stripe.

Der Symfony-Message-Handler für ProcessStripePaymentMessage ändert den Bestellstatus von pending auf paid, löst alle nachgelagerten Prozesse aus und schreibt das Stripe-Payment-Intent-ID in die Bestellung für spätere Rückerstattungs-Workflows. Weil dieser Handler asynchron über den Symfony Messenger läuft, ist die Webhook-Response sofort mit HTTP 200 fertig — die schwere Arbeit findet außerhalb des HTTP-Request-Lifecycle statt. Transaktions-E-Mails werden über Symfony Mailer versendet, und wenn der Handler mit einer Exception fehlschlägt, landet die Message in der Failed-Queue und kann manuell nachverarbeitet werden.

7. Rückerstattungen korrekt abwickeln

Rückerstattungen in einer Stripe-Symfony-Integration folgen demselben Muster wie Zahlungen: Die Aktion selbst — den Refund bei Stripe erstellen — passiert über die Stripe-API. Die Konsequenzen für die Symfony-Anwendung — Bestellstatus aktualisieren, Lagerbestand zurückbuchen, Buchhaltungs-Event auslösen — passieren über einen Webhook. Der relevante Event ist charge.refunded oder refund.created. Partielle Rückerstattungen sind möglich: Stripe liefert den erstatteten Betrag im Event, den die Symfony-Anwendung gegen den ursprünglichen Zahlbetrag verrechnet.

Der Symfony-Controller für Rückerstattungen prüft zuerst, ob die Bestellung dem eingeloggten Nutzer gehört und ob sie sich in einem erstattbaren Status befindet. Dann ruft er den Stripe-Service auf, der den Refund über die API erstellt. Das Payment-Intent-ID, das bei der Webhook-Verarbeitung der ursprünglichen Zahlung gespeichert wurde, ist die Referenz für den Refund. Stripe schickt nach dem Refund einen Webhook, der den endgültigen Status bestätigt. Erst dann wird der Bestellstatus in Symfony auf refunded gesetzt und die Bestätigungs-E-Mail versendet. Nie den Status direkt nach dem API-Aufruf setzen — nur nach Webhook-Bestätigung.


<?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. Fehlerfälle und Monitoring

Eine produktionsreife Stripe-Integration in Symfony muss Fehlerfälle systematisch behandeln und beobachtbar sein. Die häufigsten Fehlerfälle: Stripe-API nicht erreichbar beim Erstellen der Checkout-Session, Webhook-Signatur ungültig durch falsch konfigurierten Key, Message-Handler wirft Exception und landet in der Failed-Queue, oder der Nutzer bricht den Checkout ab. Für jeden Fall braucht es eine definierte Reaktion im Symfony-Code und einen Monitoring-Eintrag.

Das Stripe-Dashboard bietet eingebautes Webhook-Monitoring: Man sieht, welche Events zugestellt wurden, welche einen Fehler zurückgaben und welche Stripe gerade retryt. Auf der Symfony-Seite schreibt der Webhook-Controller bei fehlgeschlagener Signaturprüfung einen strukturierten Log-Eintrag über Monolog, inklusive Remote-IP und truncated Header. Symfony Messenger's Failed-Queue macht fehlgeschlagene Message-Handler-Aufrufe sichtbar und ermöglicht manuelles Retry. Ein Health-Check-Endpoint prüft, ob die Stripe-API erreichbar ist, indem ein Testaufruf gegen stripe.balance.retrieve() gemacht wird — ideal für Kubernetes-Readiness-Probes.

9. Stripe vs. andere Zahlungsanbieter in Symfony

Die Wahl des Zahlungsanbieters hat Auswirkungen auf die Integrationstiefe in Symfony. Stripe ist die erste Wahl für viele Projekte, aber nicht die einzige Option.

Kriterium Stripe PayPal Mollie
PHP SDK Qualität Exzellent, typsicher Gut, aber älter Sehr gut
Webhook-Zuverlässigkeit Hoch, mit Retry und Dashboard Mittel Hoch
Lokales Testing Stripe CLI für Webhooks Sandbox, kein CLI Testmodus, kein CLI
SEPA / Lastschrift DE Unterstützt Begrenzt Stark (NL-Anbieter)
Symfony-Bundle verfügbar Kein offizielles Bundle Inoffiziell mollie/mollie-api-php

Für die meisten Symfony-Projekte mit internationalem Fokus ist Stripe die beste Wahl: Das PHP SDK ist typsicher und gut gepflegt, die Entwicklererfahrung mit Stripe CLI für lokale Webhooks ist unerreicht, und das Dashboard bietet exzellentes Monitoring. Mollie ist die bessere Wahl, wenn SEPA-Lastschrift und niederländische oder belgische Zahlungsmethoden wie iDEAL wichtig sind. PayPal wird oft zusätzlich zu einem anderen Anbieter angeboten, selten als einzige Zahlungsmethode, weil die Integration in Symfony komplexer ist und die Webhook-Zuverlässigkeit geringer.

Mironsoft

Symfony Zahlungsintegration, Stripe und sichere Payment-Infrastruktur

Stripe sicher in Symfony integrieren?

Wir implementieren Stripe-Integrationen in Symfony mit Webhook-Signaturprüfung, Idempotenz-Schutz, asynchroner Auftragsverarbeitung und vollständigem Monitoring für euren Payment-Stack.

Checkout & Webhooks

Stripe Checkout Session, Webhook-Endpunkt mit Signaturprüfung und asynchroner Verarbeitung

Idempotenz

Doppelverarbeitung von Webhooks verhindern und atomare Transaktionen für Zahlungslogik

Monitoring & Testing

Stripe CLI für lokale Webhooks, PHPUnit-Tests mit gemocktem SDK und Produktions-Monitoring

10. Zusammenfassung

Eine saubere Stripe-Integration in Symfony folgt einem klaren Muster: Checkout-Session erstellen und Nutzer weiterleiten, Webhook mit Signaturprüfung empfangen, Event asynchron via Symfony Messenger verarbeiten, Auftragslogik erst nach bestätigter Zahlung ausführen. Die Idempotenz-Prüfung über gespeicherte Event-IDs verhindert Doppelverarbeitung bei wiederholten Webhook-Zustellungen. Rückerstattungen laufen über die Stripe-API und werden durch den charge.refunded-Webhook bestätigt — nie direkt nach dem API-Aufruf.

Der größte Sicherheitsgewinn liegt in der konsequenten Signaturprüfung jedes eingehenden Webhooks. Ohne diese Prüfung ist der Endpunkt ein offenes Sicherheitsrisiko. Mit ihr und dem Idempotenz-Schutz ist die Stripe-Symfony-Integration auch unter Last, nach Netzwerkfehlern und bei Stripe-Retries stabil und vorhersagbar. Das Stripe CLI macht lokale Entwicklung und Tests so einfach wie mit echter Stripe-Integration — ohne öffentliche Domains oder Ngrok-Tunnel.

Symfony + Stripe — Das Wichtigste auf einen Blick

Webhook-Sicherheit

Webhook::constructEvent() prüft Signatur. Ohne Prüfung ist der Endpunkt offen für gefälschte Zahlungsbestätigungen. Webhook-Secret als Umgebungsvariable.

Idempotenz

Verarbeitete Event-IDs in der DB speichern. Doppelte Events still überspringen. Eintrag und Auftragsverarbeitung in einer atomaren Transaktion.

Auftragslogik

Bestellung erst nach Webhook-Bestätigung auf paid setzen. Asynchron via Symfony Messenger — kein schwerer Code im Webhook-Request.

Lokales Testing

Stripe CLI weiterleitet Webhooks lokal: stripe listen --forward-to localhost:8000/webhook/stripe. Testmodus mit sk_test_-Keys nutzen.

11. FAQ: Symfony + Stripe Zahlungsintegration

1Warum Webhook-Signaturprüfung?
Ohne Prüfung kann jeder gefälschte Zahlungsbestätigungen senden. Webhook::constructEvent() validiert Stripe-Signature-Header gegen das Webhook-Secret.
2Was ist Webhook-Idempotenz?
Stripe liefert Webhooks mindestens einmal. Verarbeitete Event-IDs in der DB speichern und beim zweiten Aufruf still überspringen — verhindert Doppelbestellungen.
3Wann Auftragslogik ausführen?
Erst nach checkout.session.completed-Webhook. Bestellung bleibt bis dahin pending. Nie vor dem Stripe-Checkout.
4Stripe-Webhooks lokal testen?
Stripe CLI: stripe listen --forward-to localhost:8000/webhook/stripe. Leitet echte Test-Events an den lokalen Server weiter — inkl. korrekter Signatur.
5Rückerstattungen korrekt abwickeln?
Refund via Stripe-API erstellen. Bestellstatus erst nach charge.refunded-Webhook setzen. Payment-Intent-ID bei ursprünglicher Bestellung speichern.
6Warum Symfony Messenger für Webhooks?
Stripe erwartet innerhalb von 30 Sekunden eine Antwort. Messenger dispatcht schwere Arbeit asynchron — HTTP 200 sofort, E-Mail und Lager danach.
7Stripe Secret Key sicher speichern?
Als Umgebungsvariable STRIPE_SECRET_KEY. Symfony Secrets für Produktion. Lokal in .env.local, die nicht ins Repo kommt.
8Was passiert bei Server-Downtime?
Stripe retryt mit exponentiellem Backoff über mehrere Stunden. Idempotenz-Prüfung verhindert Doppelverarbeitung wenn der Server wieder läuft.
9Welche Stripe-Events mindestens verarbeiten?
checkout.session.completed, charge.refunded, payment_intent.payment_failed. Alle anderen Events loggen für spätere Analyse.
10Stripe-Service in PHPUnit-Tests mocken?
StripeService als Interface abstrahieren. In Tests durch Mock ersetzen. StripeClient per DI injizieren, nie global setzen. Stripe CLI für Integrationstests.