Stripe Payment Integration in PHP: SDK-Setup und erste Zahlung
Was bedeutet Stripe-Integration? Im Kern geht es darum, eine Stripe-Zahlung in PHP einzubinden: das offizielle stripe/stripe-php SDK im Backend, Stripe.js im Frontend und einen Webhook-Endpoint für asynchrone Events. Kartendaten berühren den eigenen Server nie, und die API übernimmt die regulatorischen Anforderungen (3D Secure, SCA, Fraud-Screening).
Der Ablauf in einem Satz: der Server erstellt einen PaymentIntent, der Browser des Kunden bestätigt ihn über Stripe.js, und ein Webhook teilt dem System mit, wann das Geld tatsächlich angekommen ist. Vier Komponenten greifen ineinander: ein API-Schlüssel auf dem Server, ein PaymentIntent-Objekt für den einzelnen Zahlungsversuch, ein JavaScript-Widget für die Karteneingabe (damit PCI-Scope nicht den eigenen Server betrifft) und ein Webhook-Receiver für den finalen Status.
Dieser Artikel deckt die Integration Schritt für Schritt ab, einschließlich der Details, an denen es oft hakt: 3D Secure, Währungseinheiten, Modus-Mismatch. Die Code-Beispiele zielen auf die aktuelle Major-Version des SDK; composer show stripe/stripe-php zeigt die installierte Version.
Zwei Repositories zum Merken:
- github.com/stripe/stripe-php – Quellcode, Issue-Tracker und Release-Notes der Stripe PHP Library.
- github.com/stripe-samples – lauffähige Referenz-Integrationen von Stripe. Das
accept-a-payment-Sample enthält einen PHP-Server plus passendes HTML-Frontend.
Stripe PHP SDK installieren
Das SDK ist ein normales Composer-Paket:
composer require stripe/stripe-php
Damit kommen die PHP-Extensions curl, json und mbstring als Abhängigkeiten mit, die bei jeder vernünftigen PHP-Installation schon aktiv sind. Das SDK verlangt PHP 7.2 als Minimum, aber alles unter PHP 8.1 ist praktisch am Ende des Security-Support. In Produktion auf 8.1+ setzen.
Composer-Autoloader einmal einbinden, dort wo die Anwendung startet:
require __DIR__ . '/vendor/autoload.php';
Installation ohne Composer
Auf Hosting-Umgebungen ohne Shell-Zugang oder in Projekten ohne composer.json: das SDK als ZIP von der GitHub-Releases-Seite herunterladen und den mitgelieferten Autoloader laden:
require __DIR__ . '/stripe-php/init.php';
Funktioniert, aber ohne automatische Updates. Bei einem Security-Patch muss man den Ordner manuell ersetzen. Composer lohnt sich auch auf günstigem Shared-Hosting, die meisten Anbieter bieten SSH oder cPanel-Zugang.
Installation prüfen
Bevor man Zahlungslogik schreibt, kurz prüfen, ob das SDK geladen ist:
require __DIR__ . '/vendor/autoload.php';
echo 'SDK version: ' . \Stripe\Stripe::VERSION . PHP_EOL;
echo 'cURL: ' . (extension_loaded('curl') ? 'yes' : 'no') . PHP_EOL;
echo 'PHP: ' . PHP_VERSION . PHP_EOL;
Wenn das ohne Fatal Error durchläuft, sind die API-Schlüssel der nächste Schritt.
API-Schlüssel: Test- und Live-Modus
Jedes Stripe-Konto hat zwei getrennte Schlüsselpaare. Test-Schlüssel beginnen mit sk_test_ und pk_test_, Live-Schlüssel mit sk_live_ und pk_live_. Die sind nicht austauschbar: ein Objekt, das mit Test-Schlüsseln erstellt wurde, existiert nur im Testmodus. Einen Publishable Key des einen Modus mit dem Secret Key des anderen zu kombinieren, erzeugt einen resource_missing-Fehler, der leicht als Bug im eigenen Code fehlinterpretiert wird.
Schlüssel gibt es im Stripe Dashboard unter Developers > API keys. Der Publishable Key darf im Frontend-Code stehen; der Secret Key gehört auf den Server. Secret Keys nie in Git committen, auch nicht in .env.example. Falls ein Key versehentlich in die History gelangt: sofort rotieren.
Laden über Environment-Variablen:
$secret = getenv('STRIPE_SECRET_KEY');
if (false === $secret || '' === $secret) {
throw new RuntimeException('STRIPE_SECRET_KEY is not set');
}
\Stripe\Stripe::setApiKey($secret);
Die explizite Prüfung erspart die kryptische Stripe-Meldung “Invalid API Key provided”, wenn die Variable fehlt. Ein häufiges Problem: der Key steht in .env, aber das Framework lädt ihn nur für die eigene Config, nicht für getenv().
Für Projekte mit mehreren Stripe-Konten (Multi-Tenant-Setups) statt der globalen statischen Methode einen Client pro Request nutzen:
$stripe = new \Stripe\StripeClient([
'api_key' => getenv('STRIPE_SECRET_KEY'),
'stripe_version' => '2025-03-31.basil',
]);
Die API-Version explizit zu pinnen schützt vor Überraschungen, wenn Stripe Änderungen ausrollt. Die aktuell gesetzte Version steht im Dashboard unter Developers > API version.
Restricted Keys für Produktion
Der volle Secret Key hat Zugriff auf alles: Zahlungen erstellen, Refunds auslösen, Kundendaten exportieren. Für einen Zahlungs-Flow, der nur PaymentIntents erstellen muss, einen Restricted Key verwenden: Developers > API keys > Create restricted key, dann nur PaymentIntents: write freigeben. Falls der Key leakt, ist der Schaden ein Bruchteil von dem, was ein voller Secret Key anrichten kann.
Den ersten PaymentIntent erstellen
Der PaymentIntent ist Stripes primäres Zahlungsobjekt. Statt eines einzelnen “Karte belasten”-Aufrufs erstellt man einen Intent, der beschreibt, was eingesammelt werden soll, und bestätigt ihn dann. Dieser zweistufige Ablauf ermöglicht regulatorische Anforderungen wie SCA und 3D Secure, ohne dass der eigene Code sie verstehen muss.
Der minimale Create-Aufruf:
$stripe = new \Stripe\StripeClient(getenv('STRIPE_SECRET_KEY'));
$intent = $stripe->paymentIntents->create([
'amount' => 2500,
'currency' => 'eur',
'automatic_payment_methods' => ['enabled' => true],
'metadata' => [
'order_id' => (string) $orderId,
],
]);
echo $intent->client_secret;
metadata wird von Stripe auf jedem Objekt und jedem Webhook-Event zurückgegeben. Das macht es zur saubersten Brücke zwischen eigenen IDs und Stripe-Objekten. Werte müssen Strings sein, daher numerische IDs explizit casten.
Der amount-Parameter ist in der kleinsten Währungseinheit: 2500 heißt 25,00 EUR, nicht 2.500 EUR. Für EUR, USD und GBP sind das Cent. Null-Dezimal-Währungen wie JPY werden als Ganzzahl übergeben – die vollständige Liste findet sich in den Stripe-Docs.
Currency-Codes in Kleinbuchstaben: 'eur', nicht 'EUR'. Stripe akzeptiert Großbuchstaben bei manchen Endpoints und lehnt sie bei anderen ab, die Fehlermeldung erwähnt die Groß-/Kleinschreibung nicht.
Der client_secret auf dem Intent ist kein Geheimnis auf dem Niveau des Secret Keys. Er ist ein per-Intent-Token, mit dem der Browser genau diese eine Zahlung abschließen kann. Per Template-Rendering oder JSON-Endpoint an den Browser übergeben.
Idempotency Keys
Netzwerkprobleme passieren. Wenn der Server auf die Stripe-Antwort wartet und ein Timeout eintritt, ist unklar, ob der PaymentIntent erstellt wurde oder nicht. Blind erneut senden riskiert doppelte Intents. Der Idempotency Key löst das: Stripe merkt sich den Key für 24 Stunden und gibt bei einem Retry das ursprüngliche Ergebnis zurück.
$intent = $stripe->paymentIntents->create(
[
'amount' => 2500,
'currency' => 'eur',
'automatic_payment_methods' => ['enabled' => true],
],
[
'idempotency_key' => 'order_' . $orderId . '_attempt',
]
);
Als Key etwas verwenden, das an die eigene Domain-Logik gebunden ist: eine Bestell-ID, einen Warenkorb-Hash. Nicht uniqid() oder rand() – die erzeugen bei jedem Aufruf einen neuen Key und machen den Schutz wirkungslos.
Die Client-Seite: Payment Element
Das Payment Element ist Stripes aktueller empfohlener Ansatz für die Frontend-Seite. Es rendert alle aktivierten Zahlungsmethoden in einem iFrame – Karte, Apple Pay, Google Pay, Link, regionale Methoden wie iDEAL oder SEPA je nach Standort des Kunden. Stripe.js einmal einbinden und das Element in ein <div> mounten:
<!DOCTYPE html>
<html>
<head>
<script src="https://js.stripe.com/v3/"></script>
</head>
<body>
<form id="payment-form">
<div id="payment-element"></div>
<button type="submit" id="submit">Bezahlen</button>
<div id="error-message"></div>
</form>
<script>
const stripe = Stripe(<?= json_encode($publishableKey) ?>);
const clientSecret = <?= json_encode($clientSecret) ?>;
const elements = stripe.elements({ clientSecret });
const paymentElement = elements.create('payment');
paymentElement.mount('#payment-element');
document.getElementById('payment-form').addEventListener('submit', async (e) => {
e.preventDefault();
const { error } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: 'https://example.com/order/complete',
},
});
if (error) {
document.getElementById('error-message').textContent = error.message;
}
});
</script>
</body>
</html>
Die return_url ist die Seite, zu der Stripe den Kunden nach der 3D-Secure-Authentifizierung (falls nötig) oder nach einer anderen Redirect-basierten Zahlungsmethode weiterleitet. Der Code an dieser URL muss den PaymentIntent abrufen und den Status prüfen.
json_encode() statt htmlspecialchars() für Werte im JavaScript verwenden. HTML-Escaping deckt nicht die Zeichen ab, die in einem JS-String-Kontext relevant sind – einfache/doppelte Anführungszeichen, Backslashes, Line-Terminatoren. json_encode erzeugt für jeden String-Input ein gültiges JS-Literal.
Zahlung auf dem Server bestätigen
Nach der Bestätigung im Browser leitet Stripe an die return_url weiter, mit zwei Query-Parametern: payment_intent und payment_intent_client_secret. Der Browser hat den Erfolg schon gesehen, aber dem Browser vertraut man nicht. Server-seitig verifizieren, bevor die Bestellung als bezahlt markiert wird:
$stripe = new \Stripe\StripeClient(getenv('STRIPE_SECRET_KEY'));
$intentId = $_GET['payment_intent'] ?? null;
if (!$intentId) {
http_response_code(400);
exit('Missing payment intent');
}
$intent = $stripe->paymentIntents->retrieve($intentId);
// client_secret aus der URL muss mit dem Intent übereinstimmen.
// Schützt davor, dass jemand eine fremde payment_intent-ID einschleust.
if (($_GET['payment_intent_client_secret'] ?? '') !== $intent->client_secret) {
http_response_code(400);
exit('Client secret mismatch');
}
switch ($intent->status) {
case 'succeeded':
markOrderAsPaid($intent->metadata['order_id'], $intent);
header('Location: /order/thanks');
break;
case 'processing':
// SEPA-Lastschriften und Banküberweisungen werden asynchron abgewickelt.
// Dem Kunden mitteilen, dass die Bestätigung per E-Mail kommt.
markOrderAsPending($intent->metadata['order_id']);
header('Location: /order/pending');
break;
case 'requires_payment_method':
// Vorheriger Versuch fehlgeschlagen. Zurück zum Formular.
header('Location: /checkout?error=1');
break;
default:
error_log("Unexpected intent status: {$intent->status}");
header('Location: /order/error');
}
Der Redirect-Flow ist für einfache Fälle ausreichend, aber nicht die einzige Wahrheit. Ein Kunde, der den Tab schließt, nachdem Stripe weitergeleitet hat, aber bevor der Handler läuft, hinterlässt einen bezahlten Intent ohne Datenbank-Update. Ein Webhook auf payment_intent.succeeded schließt diese Lücke: er aktualisiert die Datenbank unabhängig davon, ob der Kunde zur Seite zurückgekehrt ist.
3D Secure und SCA
PSD2 in Europa verlangt Strong Customer Authentication für die meisten Kartenzahlungen. In der Praxis heißt das: manche Karten fordern den Kunden auf, die Zahlung über die Banking-App oder per SMS-Code zu bestätigen. Der PaymentIntent regelt das automatisch – das Payment Element löst die Abfrage aus, der Kunde schließt sie ab, Stripe leitet zurück.
Wenn die Authentifizierung nicht klappt, liegt es meistens an einer fehlenden return_url. Server-seitige Bestätigung mit confirm: true beim Create-Aufruf braucht die Return-URL, sonst weiß Stripe nicht, wohin der Kunde nach 3DS zurückgeschickt werden soll. Zweithäufigste Ursache: eine Testkarte, die gar kein 3DS auslöst. 4242 4242 4242 4242 überspringt die Authentifizierung; 4000 0025 0000 3155 erzwingt 3DS bei jeder Belastung.
Der manuelle Bestätigungs-Flow für Fälle, in denen automatische Bestätigung nicht passt (Custom Checkout, Mobile-App-Backend):
try {
$intent = $stripe->paymentIntents->create([
'amount' => 2500,
'currency' => 'eur',
'payment_method' => $paymentMethodId,
'confirm' => true,
'return_url' => 'https://example.com/return',
]);
if ('requires_action' === $intent->status) {
// Client muss 3DS abwickeln. client_secret zurückgeben,
// damit stripe.handleNextAction() im Browser laufen kann.
return ['requires_action' => true, 'client_secret' => $intent->client_secret];
}
if ('succeeded' === $intent->status) {
return ['success' => true];
}
return ['error' => 'Unexpected status: ' . $intent->status];
} catch (\Stripe\Exception\CardException $e) {
return ['error' => $e->getMessage()];
}
CardException erweitert ApiErrorException. Wenn man später breitere Catches ergänzt, die kartenspezifische Exception zuerst platzieren. Sonst landen Ablehnungen als generische API-Fehler. Die vollständige Exception-Hierarchie steht in der Fehler-Referenz.
Testen mit Testkarten
Stripe liefert eine Sammlung von Testkarten für Erfolg, Ablehnung und Authentifizierung:
| Nummer | Verhalten |
|---|---|
4242 4242 4242 4242 | Erfolg, kein 3DS |
4000 0025 0000 3155 | 3DS-Authentifizierung erforderlich |
4000 0000 0000 0002 | Generic decline |
4000 0000 0000 9995 | Insufficient funds |
4000 0000 0000 0069 | Expired card |
4000 0000 0000 0127 | Incorrect CVC |
CVC: beliebige 3 Ziffern. Ablaufdatum: ein beliebiges Datum in der Zukunft. PLZ (für Karten, die sie verlangen): beliebige 5 Ziffern.
Ein häufiger Fehler: Test-Objekte mit Live-Schlüsseln abrufen. Einen PaymentIntent mit sk_test_... erstellt und dann mit sk_live_... abgerufen, ergibt “No such payment_intent”. Das Objekt existiert, man schaut nur durch das falsche Fenster. Die Fehlermeldung sagt nicht “falscher Modus”. Details zu diesem und anderen Modus-Problemen stehen in der Fehler-Referenz.
Häufige Fehlerquellen
Publishable Key auf dem Server. Symptom: Invalid API Key provided: pk_live_... bei jedem SDK-Aufruf. Der Publishable Key gehört in den Browser, der Secret Key auf den Server.
Secret Key im Frontend-Code. Schlimmer: der Key funktioniert, und jetzt steht er im Browser des Nutzers. Sofort rotieren.
Composer-Autoloader vergessen. Class 'Stripe\StripeClient' not found. Das SDK ist installiert, aber der Autoloader fehlt im Entry-Point, der tatsächlich ausgeführt wird.
Beträge als Strings. PHP übergibt manchmal Beträge als Strings aus Formularen: 'amount' => '25.00'. Stripe erwartet Integer in Minoreinheiten. Konvertieren: (int) round($price * 100).
Angenommene Währungskonvertierung. Stripe konvertiert nicht zwischen Währungen. Zahlt ein Kunde in EUR und das Stripe-Konto ist auf USD konfiguriert, scheitert die Zahlung mit invalid_currency. Settlement- und Transaktionswährung sind getrennte Konzepte auf Konto-Ebene.
Nächste Schritte
Eine einzelne PaymentIntent-Integration ist die Basis. Für wiederkehrende Einnahmen gibt es die Subscriptions API, die PaymentIntents pro Abrechnungszyklus auf Basis von Customer- und Price-Objekten erstellt. Wenn ein eigenes Zahlungsformular nicht zum Projekt passt, verlagern Checkout Sessions den gesamten Flow auf Stripes Server. In Produktion braucht man außerdem einen Webhook-Receiver: das Redirect-basierte Verfahren ist nicht die einzige Wahrheit, weil Kunden den Tab schließen können, bevor der Handler läuft. Wenn Fehler auftreten, hilft die Übersicht der Exceptions und ihrer Bedeutungen in der Fehler-Referenz.
Ein kleiner Tipp, der sich vielfach bezahlt macht: den SDK-Logger in der Entwicklungsumgebung aktivieren. \Stripe\Stripe::setLogger($psr3Logger) akzeptiert jeden PSR-3 Logger und schreibt Request/Response-Bodies mit geschwärzten Keys. Wenn ein Aufruf Unerwartetes liefert, ist der Logger der kürzeste Weg zur Ursache.
Etwas Ungenaues auf dieser Seite entdeckt?
Fehler melden