Stripe Subscriptions in PHP: Wiederkehrende Zahlungen und Lifecycle
Eine Stripe Subscription verbindet drei Objekte: einen Customer, der zahlt, einen Price, der beschreibt, was wiederkehrt, und eine Subscription, die beides auf einen Abrechnungszyklus bindet. Einmal erstellt, verlängert Stripe das Abo, stellt pro Zyklus eine Invoice aus, belastet die hinterlegte Zahlungsmethode und feuert Webhook-Events zur Verarbeitung. Das PHP-Backend erstellt die Objekte und lauscht auf Events; Stripe betreibt die Abrechnungsschleife.
Dieser Artikel deckt die praktisch relevanten Teile ab: Subscription erstellen, die wichtigen Events behandeln, kündigen ohne eine Zahlung zu verlieren, Planwechsel mit Proration und das Customer Portal für Self-Service.
Die Beispiele zielen auf die aktuelle Stripe API (
2025-03-31und neuer). In der Version2025-03-31wurden zwei Dinge geändert: das Payment-Referenzfeld auf der Invoice wurde umstrukturiert (payment_intent→confirmation_secret+payments-Sub-Resource), undInvoice::upcomingwurde zuInvoice::createPreviewumbenannt.
Das Drei-Objekte-Modell
Eine Subscription ist ein Verknüpfungsobjekt. Man kann sie nicht ohne Weiteres erstellen – sie braucht einen Customer (wer bezahlt, welche Zahlungsmethode hinterlegt ist) und mindestens einen Price (wie viel, welche Währung, wie oft). Die Reihenfolge:
- Ein
Productbeschreibt, was man verkauft (“Pro-Plan”, “Gold-Stufe”). Einmal im Dashboard oder per API erstellen. - Ein
Pricehängt Geld an ein Product – Betrag, Währung, Intervall. Ein Product hat in der Regel mehrere Prices (monatlich, jährlich, verschiedene Währungen). - Ein
Customerist der Käufer. Er trägt E-Mail, Standard-Zahlungsmethode und die Subscriptions. - Eine
Subscriptionist die wiederkehrende Belastung: Customer, ein oder mehrere Prices, ein Zeitplan, ein Status.
Das Plan-Objekt aus älteren Tutorials ist deprecated. Die API akzeptiert noch plan-IDs, aber neue Integrationen verwenden Product + Price. Wenn in einem 2019er-Blogpost $stripe->plans->create(...) auftaucht, ist Price das Äquivalent.
Ein minimales Abo
Der kürzeste funktionierende Flow: Customer erstellen, PaymentMethod anhängen, Subscription erstellen und die erste Invoice expandieren, damit der client_secret für 3D Secure in der gleichen Response zurückkommt.
<?php
require __DIR__ . '/vendor/autoload.php';
\Stripe\Stripe::setApiKey(getenv('STRIPE_SECRET_KEY'));
$customer = \Stripe\Customer::create([
'email' => $user->email,
'payment_method' => $paymentMethodId,
'invoice_settings' => [
'default_payment_method' => $paymentMethodId,
],
]);
$subscription = \Stripe\Subscription::create([
'customer' => $customer->id,
'items' => [[ 'price' => 'price_YOUR_MONTHLY_ID' ]],
'payment_behavior' => 'default_incomplete',
'payment_settings' => [ 'save_default_payment_method' => 'on_subscription' ],
'expand' => ['latest_invoice.confirmation_secret'],
]);
$clientSecret = $subscription->latest_invoice->confirmation_secret->client_secret;
// $clientSecret an den Client schicken; stripe.confirmCardPayment handhabt 3DS
Drei Flags steuern den Flow:
payment_behavior: 'default_incomplete' weist Stripe an, die Subscription im Status incomplete zu erstellen, wenn die erste Invoice Authentifizierung braucht (3D Secure, SCA). Ohne dieses Flag schlägt die API fehl, wenn die Bank einen zusätzlichen Schritt verlangt. Mit dem Flag bekommt man einen client_secret und bestätigt im Browser.
expand: ['latest_invoice.confirmation_secret'] zieht den Confirmation Secret in die Response. Auf aktuellen API-Versionen liegt der Client Secret unter latest_invoice.confirmation_secret.client_secret, nicht unter latest_invoice.payment_intent.client_secret – der alte Pfad ist der Grund, warum viele kopierte Beispiele seit 2025 nicht mehr funktionieren.
save_default_payment_method: 'on_subscription' macht die Zahlungsmethode, die die erste Invoice bezahlt hat, zur Standard-Methode des Customers. Verlängerungen nutzen sie ab dann automatisch. Ohne dieses Flag schlägt der zweite Zyklus mit “no payment method on file” fehl.
Die Subscription startet als incomplete. Nach erfolgreicher Client-Bestätigung wechselt Stripe auf active und feuert customer.subscription.created plus invoice.paid.
Über Checkout
Wer die PaymentMethod nicht selbst erfassen will, übergibt den gesamten Flow an Stripe Checkout. line_items auf einen wiederkehrenden Price zeigen und mode auf subscription setzen:
$session = \Stripe\Checkout\Session::create([
'mode' => 'subscription',
'line_items' => [[ 'price' => 'price_YOUR_MONTHLY_ID', 'quantity' => 1 ]],
'success_url' => 'https://example.com/welcome?session_id={CHECKOUT_SESSION_ID}',
'cancel_url' => 'https://example.com/pricing',
]);
Stripe erstellt den Customer, erfasst die PaymentMethod, führt 3DS bei Bedarf durch und erstellt die Subscription. payment_intent_data darf bei mode: 'subscription' nicht übergeben werden – die API gibt invalid_request zurück. Bei Bedarf subscription_data verwenden, um Trial-Tage oder Metadata auf der Subscription zu setzen. Details im Checkout-Artikel.
Webhook-Events, die zählen
Subscriptions erzeugen mehr Events als jedes andere Stripe-Objekt. Die meisten kann man ignorieren. Die praktisch relevante Liste:
| Event | Feuert wenn | Warum relevant |
|---|---|---|
customer.subscription.created | Subscription wird erstmals aktiv | Zugang freischalten |
customer.subscription.updated | Status, Items oder Cancel-Flag ändern sich | Upgrades, Downgrades, Pausen |
customer.subscription.deleted | Subscription endet | Zugang entziehen |
invoice.paid | Eine Invoice wurde bezahlt (initial + jede Verlängerung) | Zugang um einen Zyklus verlängern |
invoice.payment_failed | Smart Retries haben aufgegeben oder Versuch fehlgeschlagen | Nutzer warnen, Dunning-Flow |
customer.subscription.trial_will_end | Drei Tage vor Trial-Ende | Nutzer erinnern, Zahlungsmethode hinzuzufügen |
Nur diese Events abonnieren, nicht den gesamten Subscription-Namespace. customer.subscription.pending_update_applied und ähnliche Events sind real, aber irrelevant, solange man die Features nicht nutzt, die sie auslösen.
Reihenfolge ist nicht garantiert. Stripe kann customer.subscription.updated vor customer.subscription.created zustellen. Jedes Event als eigenständige Tatsache behandeln, nicht als Schritt in einer Sequenz. Die Webhook-Referenz deckt Idempotenz über event.id und Signatur-Verifizierung ab.
Das kanonische “hat bezahlt”-Event ist invoice.paid, nicht customer.subscription.updated. Dass die Subscription auf active wechselt, heißt nicht, dass Geld geflossen ist – es heißt, dass die Subscription geplant ist. Auf das Invoice-Event warten, bevor bezahlte Features freigeschaltet werden.
Kündigung: zwei Varianten
Sofortige Kündigung beendet die Subscription auf der Stelle und stoppt zukünftige Invoices. Der Kunde verliert sofort den Zugang, auch wenn er bereits für den Monat bezahlt hat:
\Stripe\Subscription::cancel($subscriptionId);
Kündigung zum Periodenende hält die Subscription aktiv bis der bezahlte Zyklus endet, dann beendet sie ohne erneute Belastung:
\Stripe\Subscription::update($subscriptionId, [
'cancel_at_period_end' => true,
]);
In den meisten Consumer-Flows ist die zweite Variante richtig: der Kunde hat bis zum 30. bezahlt, also Zugang bis zum 30. Sofortiges cancel() verschenkt einen Monat, für den bereits bezahlt wurde, und hinterlässt einen manuellen Refund.
Eine ausstehende Kündigung rückgängig machen: cancel_at_period_end: false. Stripe behält die Subscription und berechnet normal weiter.
Stripe feuert customer.subscription.updated beim Setzen von cancel_at_period_end, und customer.subscription.deleted erst, wenn die tatsächliche Periodenende-Kündigung eintritt. Zugang nicht beim updated-Event entziehen – das würde cancel-at-period-end aushebeln. Auf deleted warten.
Trials
Stripe kennt zwei Trial-Varianten:
// Relativ: Trial dauert so viele Tage ab jetzt
'trial_period_days' => 14,
// Absolut: Trial endet zu diesem Unix-Timestamp
'trial_end' => strtotime('+30 days'),
trial_period_days für Standard-Flows (“registrieren, 14 Tage gratis”). trial_end wenn das Trial an einem Kalenderdatum enden soll – eine Launch-Kampagne mit festem Stichtag oder ein individuell verlängertes Trial. Beide gleichzeitig auf derselben Subscription zu setzen, gibt invalid_request.
Trials ohne Zahlungsmethode starten: trial_settings.end_behavior.missing_payment_method auf 'cancel', 'pause' oder 'create_invoice' setzen. Für “gratis Trial, keine Kreditkarte” ist das die Einstellung. Ohne sie verweigert Checkout die Session-Erstellung ohne hinterlegte Zahlungsmethode.
Drei Tage vor Trial-Ende feuert Stripe customer.subscription.trial_will_end. Bei Trials kürzer als drei Tage feuert es sofort bei der Erstellung. Das ist das Signal, dem Kunden eine E-Mail zu schicken und ihn darauf hinzuweisen, was als Nächstes berechnet wird. Abos, die still von “gratis” auf “kostenpflichtig” wechseln, sind eine häufige Quelle für Chargebacks.
Planwechsel und Proration
Prices auf einer laufenden Subscription austauschen:
$subscription = \Stripe\Subscription::retrieve($subscriptionId);
\Stripe\Subscription::update($subscriptionId, [
'items' => [[
'id' => $subscription->items->data[0]->id,
'price' => 'price_YOUR_YEARLY_ID', // von monatlich auf jährlich
]],
'proration_behavior' => 'create_prorations',
]);
items->data[0]->id setzt eine Single-Item-Subscription voraus. Bei Multi-Item-Subscriptions (mehrere Prices auf einem Abrechnungszyklus) die richtige SubscriptionItem-ID pro Änderung finden.
proration_behavior kennt drei Werte:
create_prorations(Standard) – Stripe berechnet den ungenutzten Anteil des alten Plans, schreibt ihn gut, und belastet den anteiligen Rest des neuen Plans. Die Differenz erscheint auf der nächsten Invoice.none– keine Proration, die nächste Invoice nutzt den neuen Preis für den vollen Zyklus. Nützlich für Downgrades, die erst ab dem nächsten Zyklus gelten sollen.always_invoice– gleiche Rechnung wiecreate_prorations, aber sofortige Belastung statt Warten auf den nächsten Zyklus. Der klassische Upgrade-Flow: jetzt upgraden, jetzt die Differenz zahlen.
Vorschau vor dem Commit über Invoice::createPreview:
$preview = \Stripe\Invoice::createPreview([
'customer' => $customer->id,
'subscription' => $subscriptionId,
'subscription_details' => [
'items' => [[
'id' => $subscription->items->data[0]->id,
'price' => 'price_YOUR_YEARLY_ID',
]],
'proration_behavior' => 'always_invoice',
],
]);
echo "Neue Belastung: {$preview->amount_due} {$preview->currency}";
Das liefert die Invoice zurück, die Stripe erzeugen würde. Damit dem Kunden “Sie zahlen heute X EUR für den Wechsel” anzeigen. Vor der Version 2025-03-31 hieß dieser Endpoint Invoice::upcoming; der alte Aufruf funktioniert bei gepinnten Legacy-Versionen, neuer Code nutzt createPreview.
Charge aus einer Subscription ermitteln
Häufige SO-Frage: “Ich habe eine Subscription erstellt, wie finde ich den PaymentIntent / Charge?” Die Kette ist lang: Subscription → Invoice → Payment-Versuche → PaymentIntent → Charge.
Seit der Version 2025-03-31 hat die Invoice eine payments-Sub-Resource für mehrere (partielle) Zahlungsversuche. Der PaymentIntent ist kein Top-Level-Feld mehr, sondern liegt unter invoice.payments.data[].payment.payment_intent:
$subscription = \Stripe\Subscription::retrieve([
'id' => $subscriptionId,
'expand' => ['latest_invoice.payments'],
]);
$payment = $subscription->latest_invoice->payments->data[0]->payment;
$pi = \Stripe\PaymentIntent::retrieve($payment->payment_intent);
$charge = \Stripe\Charge::retrieve($pi->latest_charge);
Zwei Migrationspunkte für alte Tutorials:
- Vor API
2022-11-15warPaymentIntent.chargeseine Liste; diese Version ersetzte sie durchlatest_chargeals einzelne String-ID. - Vor
2025-03-31warinvoice.payment_intentein direkt expandierbares Feld. Auf aktuellen Versionen stattdesseninvoice.payments.dataiterieren.
Customer Portal
Die “Plan ändern, Karte aktualisieren, kündigen”-UI ist viel Formular-Arbeit. Stripe liefert sie fertig: das Customer Portal. Den Kunden auf eine Stripe-gehostete Seite weiterleiten, dort erledigt er alles selbst, und Stripe feuert die gleichen Webhook-Events, die man schon verarbeitet.
$portal = \Stripe\BillingPortal\Session::create([
'customer' => $customerId,
'return_url' => 'https://example.com/account',
]);
header('Location: ' . $portal->url, true, 303);
exit;
Das ist die gesamte Integration für “Kunden kündigen lassen ohne Support-Mail”. Das Verhalten des Portals – was änderbar ist, ob Kündigung sofort oder zum Periodenende, ob Invoices angezeigt werden – wird im Dashboard konfiguriert (Settings → Billing → Customer portal) oder per \Stripe\BillingPortal\Configuration::create.
Verlängerungen testen mit Test Clocks
Jeder Abo-Guide der ersten Generation rät, eine Subscription mit kurzem Intervall zu erstellen und zu warten. Stripe hat Test Clocks genau dafür:
$clock = \Stripe\TestHelpers\TestClock::create([
'frozen_time' => time(),
]);
$customer = \Stripe\Customer::create([
'email' => '[email protected]',
'test_clock' => $clock->id,
]);
// Subscription auf $customer wie gewohnt erstellen...
// Einen Monat vorspulen
$clock->advance([
'frozen_time' => time() + 31 * 86400,
]);
Stripe verarbeitet die Subscription auf der vorgestellten Uhr: Invoice wird ausgestellt, Smart Retries laufen bei fehlgeschlagenen Zahlungen, customer.subscription.trial_will_end feuert wenn zutreffend. Nur im Testmodus, aber so lässt sich der Verlängerungs-Flow tatsächlich in CI testen. Die Stripe CLI hat stripe trigger für synthetische Events, aber getriggerte Events sind fabriziert – Test Clocks treiben die echte Abo-Mechanik an.
Troubleshooting
Allgemeine Fehlerbehandlung mit dem Stripe PHP SDK deckt die Fehler-Referenz ab. Hier die subscription-spezifischen Probleme:
invalid_request: payment_intent_data not allowed on subscription
Ein Checkout-Session-Snippet aus einer Einmalzahlungs-Integration kopiert und payment_intent_data stehen gelassen. Im Subscription-Modus subscription_data verwenden, payment_intent_data löschen.
latest_invoice->payment_intent ist null
Auf API-Version 2025-03-31 und neuer wurde Invoice.payment_intent entfernt. Code, der $subscription->latest_invoice->payment_intent->client_secret liest, gibt null zurück. Auf latest_invoice.confirmation_secret.client_secret mit dem passenden Expand-Pfad umstellen.
Invoice bleibt auf draft
Wenn auto_advance: false auf der Invoice gesetzt war, finalisiert sie sich nicht von selbst. Invoice::finalizeInvoice($invoiceId) ruft sie auf open, Invoice::pay($invoiceId) belastet tatsächlich. Der automatische Flow hat auto_advance: true standardmäßig; der manuelle Pfad ist nur für “prüfen, dann belasten”-Workflows.
Subscription ist active, aber Kunde hat keinen Zugang
active bedeutet: Stripe berechnet diese Subscription. Es bedeutet nicht, dass die aktuelle Invoice bezahlt wurde. Eine Subscription kann active sein, während die latest_invoice fehlgeschlagen ist und Smart Retries laufen. Zugang über invoice.paid-Webhook-Events und current_period_end steuern, nicht über status === 'active' allein.
Nächste Schritte
- Stripe Webhooks in PHP – Signatur-Verifizierung und idempotente Handler. Subscription-Event-Volumen macht Idempotenz nicht optional.
- Stripe Checkout in PHP –
mode: 'subscription'als Abkürzung, wenn man kein eigenes Zahlungsformular bauen will. - Payment-Integration – SDK-Setup und der erste PaymentIntent, auf dem Subscriptions aufbauen.
- Für Proration-Details (Upgrades mid-cycle, Mixed Currencies, Credit Notes) sind Stripes eigene Proration-Docs die Referenz.
Etwas Ungenaues auf dieser Seite entdeckt?
Fehler melden