A Stripe subscription ties three objects together: a Customer that pays, a Price that describes what recurs, and a Subscription that binds them on a billing schedule. Once created, Stripe renews the Subscription, issues an Invoice for each cycle, charges the saved payment method, and emits webhook events for you to react to. Your PHP backend creates the objects and listens to events; Stripe runs the billing loop.
This guide covers the parts that ship: creating the Subscription, handling the events that matter, cancelling without stranding a payment, plan changes with proration, and wiring the Customer Portal so you do not have to build a billing UI.
The examples target the current Stripe API (
2025-03-31and later). If your account is still pinned to an older version, two things changed in the 2025-03-31 release: the Invoice payment-reference field was restructured (payment_intentreplaced byconfirmation_secretplus thepaymentssub-resource), and theInvoice::upcomingendpoint was renamed toInvoice::createPreview. The end of this guide covers the migration.
Does Stripe support PHP?
Yes – stripe/stripe-php is the Stripe-maintained SDK on Packagist:
composer require stripe/stripe-php
Every example below assumes the SDK is loaded and the secret key is set at bootstrap:
\Stripe\Stripe::setApiKey(getenv('STRIPE_SECRET_KEY'));
If you have not done the initial setup or created your first PaymentIntent, the payment-integration guide covers that end to end. Subscriptions sit on top of it.
The three-object mental model
A Subscription is a join object. You cannot create it out of thin air: it needs a Customer (who is being charged, what their default payment method is) and at least one Price (how much, which currency, how often). The pieces go in this order:
- A
Productdescribes what you sell (“Pro plan”, “Gold tier”). Create it once in the Dashboard or via API. - A
Priceattaches money to a Product – amount, currency, interval. One Product usually has several Prices (monthly, yearly, different currencies). - A
Customeris the buyer. It carries the email, the default payment method, and the subscriptions. - A
Subscriptionis the recurring charge: customer, one or more prices, a schedule, a status.
The Plan object from older tutorials is deprecated. The API still accepts plan IDs, but new integrations should use Product + Price. If you see $stripe->plans->create(...) in a 2019 blog post, translate it to Price.
A minimal subscription
The shortest working flow: create the Customer, attach a PaymentMethod, create the Subscription, and expand the first invoice so the client_secret for 3D Secure confirmation comes back in the same response.
<?php
require __DIR__ . '/vendor/autoload.php';
\Stripe\Stripe::setApiKey(getenv('STRIPE_SECRET_KEY'));
$customer = \Stripe\Customer::create([
'email' => $user->email,
'payment_method' => $paymentMethodId, // from Stripe.js on the frontend
'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;
// ship $clientSecret to the client; stripe.confirmCardPayment handles 3DS
Three flags carry the flow:
payment_behavior: 'default_incomplete' tells Stripe to create the Subscription in incomplete status if the first invoice needs authentication (3D Secure, Strong Customer Authentication). Without it, the API fails hard when the bank wants an extra step, and the customer sees an opaque error. With it, you get a client_secret on the invoice’s confirmation_secret and confirm it in the browser.
expand: ['latest_invoice.confirmation_secret'] pulls the confirmation secret into the response. On current API versions, the client secret for the first invoice lives at latest_invoice.confirmation_secret.client_secret, not at latest_invoice.payment_intent.client_secret – that older path is the reason so many copy-pasted examples stopped working in 2025.
save_default_payment_method: 'on_subscription' promotes the payment method that successfully pays the first invoice to the Customer’s default. Renewals use it from then on. Skip this and the second cycle fails with “no payment method on file” even though the first one worked.
The Subscription starts in incomplete. Once the client-side confirmation succeeds, Stripe flips it to active and fires customer.subscription.created plus invoice.paid.
Via Checkout
If you do not want to collect the PaymentMethod yourself, hand the whole flow to Stripe Checkout. Point line_items at a recurring Price and switch mode:
$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 creates the Customer, collects the PaymentMethod, runs 3DS if needed, and creates the Subscription. Do not pass payment_intent_data alongside mode: 'subscription' – the API returns invalid_request. Use subscription_data instead if you need to set trial days or metadata on the Subscription. See the Checkout guide.
Webhook events that matter
Subscriptions emit more events than any other Stripe object, and most you can ignore. A practical list:
| Event | Fires when | Why you care |
|---|---|---|
customer.subscription.created | Subscription goes active for the first time | Provision access |
customer.subscription.updated | Status, items, or cancel flag changed | Handle upgrades, downgrades, pauses |
customer.subscription.deleted | Subscription ends (cancelled or ran out of retries) | Revoke access |
invoice.paid | An invoice was paid (initial + every renewal) | Extend access another cycle |
invoice.payment_failed | Smart Retries gave up, or retry attempt failed | Warn the user, dunning flow |
customer.subscription.trial_will_end | Three days before trial ends (immediately if shorter) | Nudge the customer to add payment method |
Subscribe to these specifically rather than the whole Subscription namespace. customer.subscription.pending_update_applied, .pending_update_expired, .resumed are real events, but ignore them unless you use the features that emit them.
Ordering is not guaranteed. Stripe can deliver customer.subscription.updated before customer.subscription.created in pathological cases. Treat each event as a standalone fact, not a step in a sequence. See the webhooks guide for idempotency-by-event-id and raw-body signature verification. Both are required.
The canonical “did they pay?” event is invoice.paid, not customer.subscription.updated. The Subscription object flipping to active does not mean money moved – it means the Subscription is scheduled. Wait for the invoice event before unlocking paid features.
Cancellation: the two flavors
Two ways to end a Subscription, with very different billing consequences.
Immediate cancellation ends the Subscription on the spot and stops future invoicing. The customer loses access immediately, even if they already paid for the month:
\Stripe\Subscription::cancel($subscriptionId);
Cancel at period end keeps the Subscription active until the paid cycle finishes, then ends it without re-billing:
\Stripe\Subscription::update($subscriptionId, [
'cancel_at_period_end' => true,
]);
In most consumer flows, the second one is what you want: the customer paid through the 30th, give them access until the 30th. Calling cancel() immediately throws away a month they already paid for and leaves you a manual refund to process.
Reversing a pending cancellation is the same update in reverse – cancel_at_period_end: false. Stripe keeps the Subscription as-is and bills normally at the next cycle.
Stripe fires customer.subscription.updated when you set cancel_at_period_end, and customer.subscription.deleted only when the actual end-of-period cancellation happens. Do not revoke access on the updated event – that defeats cancel-at-period-end. Wait for deleted.
Trials
Stripe has two ways to express a trial, and they do different things:
// Relative: trial lasts this many days from now
'trial_period_days' => 14,
// Absolute: trial ends at this exact Unix timestamp
'trial_end' => strtotime('2026-05-01T00:00:00Z'),
Use trial_period_days for standard “sign up, get 14 days free” flows. Use trial_end when you need to align trials with a calendar date – a launch campaign that ends at a specific moment, or extending one customer’s trial while leaving everyone else’s alone. Mixing the two on the same Subscription returns invalid_request.
Subscriptions in trial used to require a payment method up front, charged nothing during the trial, then charged on day 15. You can now start trials without collecting a payment method by setting trial_settings.end_behavior.missing_payment_method to 'cancel', 'pause', or 'create_invoice'. If you want “free trial, no credit card”, this is the setting. Without it, Checkout still refuses to create the Session without a payment method attached.
Three days before the trial ends, Stripe fires customer.subscription.trial_will_end. For trials shorter than three days, it fires immediately on Subscription creation. That is the signal to email the customer, remind them what they are about to be charged, and maybe offer a discount to stick around. Subscriptions that silently flip from “free” to “charged” are a common source of chargebacks; this event is the prompt to avoid them.
Plan changes and proration
You can swap Prices on a running Subscription. What changes is the money:
$subscription = \Stripe\Subscription::retrieve($subscriptionId);
\Stripe\Subscription::update($subscriptionId, [
'items' => [[
'id' => $subscription->items->data[0]->id,
'price' => 'price_YOUR_YEARLY_ID', // switching from monthly to yearly
]],
'proration_behavior' => 'create_prorations',
]);
items->data[0]->id assumes a single-item Subscription, which is the common case. Multi-item subscriptions (several Prices on one billing cycle) need the right SubscriptionItem ID per change – iterate items->data and match on price->id or whatever discriminator you track.
proration_behavior takes three values:
create_prorations(default) – Stripe computes the unused portion of the old plan, credits the customer, charges them for the new plan’s prorated remainder. The difference shows up on the next invoice.none– no prorating, next invoice uses the new price for the full cycle. Useful for downgrades you want to take effect next cycle, not mid-period.always_invoice– same math ascreate_prorations, but bills immediately instead of waiting for the next cycle. Classic upgrade flow: upgrade now, pay the difference now.
Preview the damage before committing by calling Invoice::createPreview with the same change as a simulation:
$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 "New charge: {$preview->amount_due} {$preview->currency}";
This returns the invoice Stripe would generate. Use it to show the customer “you’ll be charged $X today to switch” before they confirm. Before the 2025-03-31 release this endpoint was Invoice::upcoming with a flat subscription_proration_behavior parameter; the old call still exists on pinned legacy versions but new code should use createPreview.
Getting the charge from a Subscription
Classic Stack Overflow question: “I created a Subscription, how do I find the PaymentIntent / Charge?” The chain is long because a Subscription points to an Invoice, the Invoice has one or more payment attempts, and each attempt points to a PaymentIntent that points to a Charge.
Since the 2025-03-31 release, the Invoice gained a payments sub-resource to support multiple (partial) payment attempts. The PaymentIntent is no longer a top-level field on the Invoice – it lives inside invoice.payments.data[].payment.payment_intent.
The payment.payment_intent field is a string ID, so a second retrieve is required – and latest_charge is another string ID under the PaymentIntent, so the Charge takes a third:
$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);
Two migration points to remember when you land on old tutorials:
- Before API
2022-11-15,PaymentIntent.chargeswas a list; that version replaced it withlatest_chargeas a single string ID. - Before the 2025-03-31 release,
invoice.payment_intentwas a direct expandable field. On current versions, iterateinvoice.payments.datainstead.
The Customer Portal
The “change my plan, update my card, cancel” UI is a lot of forms, confirmation dialogs, and dunning copy. Stripe ships one: the Customer Portal. You redirect the customer to a Stripe-hosted page, they do whatever they need, and Stripe emits the same webhook events you already handle.
$portal = \Stripe\BillingPortal\Session::create([
'customer' => $customerId,
'return_url' => 'https://example.com/account',
]);
header('Location: ' . $portal->url, true, 303);
exit;
That is the whole integration for “let customers cancel without emailing support”. The portal’s exact behaviour – what they can change, whether cancellation is immediate or at period end, whether they see invoices – is configured once in the Dashboard (Settings > Billing > Customer portal) or via \Stripe\BillingPortal\Configuration::create. Session and Configuration are deliberately separate: the Session is a one-off redirect, the Configuration survives across them.
Testing renewals with test clocks
Every gen-one subscription guide tells you to create a Subscription with a short interval and wait. Stripe ships test clocks for exactly this reason – a shared waiting room is not a renewal test:
$clock = \Stripe\TestHelpers\TestClock::create([
'frozen_time' => time(),
]);
$customer = \Stripe\Customer::create([
'email' => '[email protected]',
'test_clock' => $clock->id,
]);
// Create Subscription on $customer as usual...
// Fast-forward one month
$clock->advance([
'frozen_time' => time() + 31 * 86400,
]);
Stripe processes the Subscription on the advanced clock: invoice is issued, Smart Retries run on failed payments, customer.subscription.trial_will_end fires if applicable. Test-mode only, but this is how you actually exercise the renewal flow in CI. The stripe-cli has a stripe trigger command that also sends synthetic events, but triggered events are fabricated – test clocks drive the real Subscription machinery.
Troubleshooting
invalid_request: payment_intent_data not allowed on subscription
Copying a Checkout Session snippet from a one-off payment integration and keeping payment_intent_data in the config is the fastest way to hit this. Subscription mode has its own subscription_data with a similar shape. Delete payment_intent_data, use subscription_data if you need metadata on the Subscription, done.
latest_invoice->payment_intent is null
On API version 2025-03-31 and later, Invoice.payment_intent was removed. Code that reads $subscription->latest_invoice->payment_intent->client_secret either returns null or throws an “undefined property” notice depending on PHP configuration. Update to latest_invoice.confirmation_secret.client_secret with the matching expand path. If you genuinely need to stay on the old field, pin the API version per-call via ['stripe_version' => '2024-12-18'] and know you are on borrowed time.
Invoice stuck in draft
If auto_advance: false was set on the invoice, it never finalizes on its own. Call Invoice::finalizeInvoice($invoiceId) to push it to open, and Invoice::pay($invoiceId) to actually charge. The automatic flow has auto_advance: true baked in by default; the manual path is only for “preview, review, then charge” workflows.
Subscription says active but customer has no access
active means “Stripe is billing this Subscription”. It does not mean the current invoice was paid. A Subscription can be active with a failed latest_invoice while Smart Retries work through the schedule. Guard access on invoice.paid webhook events and the Subscription’s current_period_end, not on status === 'active' alone.
Renewal charge used the old card
Cards expire. Stripe’s card updater transparently refreshes card details for many issuers, but not all. When it does not apply, the renewal payment fails and the customer gets invoice.payment_failed. Build the dunning flow around that event: email, retry prompt, maybe a grace period on access.
What next
- Stripe webhooks in PHP – signature verification and idempotent handlers. Subscription event volume makes idempotency not optional.
- Stripe Checkout in PHP – Hosted/Embedded/Custom modes.
mode: 'subscription'is the shortcut when you do not want to build the payment form. - Stripe in Laravel without Cashier – service-provider with
StripeClientas a singleton, webhook CSRF exemption, the queue-PII trap, and the namespace collision with Eloquent’sCustomermodel. - For the proration math in detail (upgrades mid-cycle, mixed currencies, credit notes), Stripe’s own proration docs are the reference.
Spotted something inaccurate on this page?
Report an error