phpguzzle.org
enrudees
Stripe – Doku

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-31 und neuer). In der Version 2025-03-31 wurden zwei Dinge geändert: das Payment-Referenzfeld auf der Invoice wurde umstrukturiert (payment_intentconfirmation_secret + payments-Sub-Resource), und Invoice::upcoming wurde zu Invoice::createPreview umbenannt.

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:

  1. Ein Product beschreibt, was man verkauft (“Pro-Plan”, “Gold-Stufe”). Einmal im Dashboard oder per API erstellen.
  2. Ein Price hängt Geld an ein Product – Betrag, Währung, Intervall. Ein Product hat in der Regel mehrere Prices (monatlich, jährlich, verschiedene Währungen).
  3. Ein Customer ist der Käufer. Er trägt E-Mail, Standard-Zahlungsmethode und die Subscriptions.
  4. Eine Subscription ist 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:

EventFeuert wennWarum relevant
customer.subscription.createdSubscription wird erstmals aktivZugang freischalten
customer.subscription.updatedStatus, Items oder Cancel-Flag ändern sichUpgrades, Downgrades, Pausen
customer.subscription.deletedSubscription endetZugang entziehen
invoice.paidEine Invoice wurde bezahlt (initial + jede Verlängerung)Zugang um einen Zyklus verlängern
invoice.payment_failedSmart Retries haben aufgegeben oder Versuch fehlgeschlagenNutzer warnen, Dunning-Flow
customer.subscription.trial_will_endDrei Tage vor Trial-EndeNutzer 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 wie create_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-15 war PaymentIntent.charges eine Liste; diese Version ersetzte sie durch latest_charge als einzelne String-ID.
  • Vor 2025-03-31 war invoice.payment_intent ein direkt expandierbares Feld. Auf aktuellen Versionen stattdessen invoice.payments.data iterieren.

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 PHPmode: '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