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.
Inhaltsverzeichnis
- 1. Warum Stripe-Integration Sorgfalt erfordert
- 2. Stripe SDK und Symfony-Konfiguration
- 3. Stripe Checkout Session erstellen
- 4. Webhooks sicher verarbeiten
- 5. Idempotenz: Doppelbuchungen verhindern
- 6. Auftragslogik nach bestätigter Zahlung
- 7. Rückerstattungen korrekt abwickeln
- 8. Fehlerfälle und Monitoring
- 9. Stripe vs. andere Zahlungsanbieter in Symfony
- 10. Zusammenfassung
- 11. FAQ
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.