In Laravel Cashier, how does newSubscription(...)->checkout() start a subscription, and when does the local subscriptions row appear?
answer
- SubscriptionBuilder, then a hosted page
- Checkout is Responsable: return it
- customer.subscription.created creates the row
- success_url is not proof of payment
- Checkout trials need 48 hours
basics
~20 snewSubscription('default', $priceId) returns a SubscriptionBuilder whose checkout() creates a Stripe Checkout session and redirects there. The local subscriptions row is written later, when Cashier's webhook handler receives customer.subscription.created, not when the customer returns to success_url.
solid answer
~40 s`$user->newSubscription('default', 'price_podcast_monthly')` returns a `SubscriptionBuilder`; you chain `trialDays(14)` or `allowPromotionCodes()` and finish with `checkout(['success_url' => ..., 'cancel_url' => ...])`. That creates a Stripe Checkout session in `subscription` mode, with the type stored in metadata, and returns a `Laravel\Cashier\Checkout`, which is `Responsable`, so returning it redirects to Stripe's hosted page. No row is written yet. When the customer pays, Stripe sends `customer.subscription.created`, and Cashier's webhook controller creates the `subscriptions` and `subscription_items` rows and clears any generic trial. So `success_url` means the customer came back, not that they are subscribed; the page should tolerate a short delay. Checkout trials are bumped to at least 48 hours.
code
php · 17 lines<?php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
Route::get('/plans/{plan}/subscribe', function (Request $request, string $plan) {
$price = $plan === 'yearly' ? 'price_podcast_yearly' : 'price_podcast_monthly';
return $request->user()
->newSubscription('default', $price)
->trialDays(14)
->allowPromotionCodes()
->checkout([
'success_url' => route('billing.welcome'),
'cancel_url' => route('plans.index'),
]);
})->middleware('auth');go deeper
Recall the chain newSubscription('default', $price)->checkout([...]) and that the customer pays on a Stripe-hosted page, then comes back to your success URL.
Explain the builder options, what the Checkout object is, and that the subscriptions row is created by the customer.subscription.created webhook.
Design for the webhook and redirect race, never trust success URL parameters, and know the Checkout limits on trials and billing anchors.
Weigh Stripe-hosted Checkout against an embedded payment form: conversion, compliance scope, 3D Secure handling and how much billing UI the team wants to own.
## The builder On a `Billable` model, `newSubscription(string $type, string|array $prices)` returns a **`Laravel\Cashier\SubscriptionBuilder`**. The **type** is your own name for the subscription slot, usually `'default'`; the prices are Stripe price IDs. For a podcast host choosing between plans: - `newSubscription('default', 'price_podcast_monthly')` or `'price_podcast_yearly'`; - `->trialDays(14)` or `->trialUntil($date)` to start with a trial; - `->allowPromotionCodes()` or `->withCoupon('LAUNCH')` for discounts; - `->quantity(3)` for per-seat pricing. The builder then ends in one of two ways: | Finisher | What happens | When the local row is written | |---|---|---| | `checkout([...])` | creates a Stripe Checkout session and returns a `Checkout` object | later, by the `customer.subscription.created` webhook | | `create($paymentMethodId)` | creates the Stripe subscription directly with a card collected by Stripe.js | immediately, from the API response | ## What checkout() does `checkout($sessionOptions, $customerOptions)` builds a Checkout session with `mode` set to `subscription`, the price line items, any trial end, and metadata holding the subscription type. It creates the Stripe customer if the user has none, then returns a **`Laravel\Cashier\Checkout`**. That class implements `Responsable`, so a route can return it directly and Laravel turns it into a redirect to Stripe's hosted payment page; `->redirect()` does the same explicitly. You normally pass: 1. `success_url`, where Stripe sends the customer after paying; 2. `cancel_url`, where Stripe sends them if they back out. ## Why the row appears later Checkout runs entirely on Stripe. Some payment methods settle after a delay, and the customer may close the tab before reaching `success_url`. Cashier therefore does **not** create the subscription row when the customer returns. Instead: 1. Stripe creates the subscription and sends **`customer.subscription.created`** to `/stripe/webhook`; 2. Cashier's `WebhookController` finds the user by `stripe_id`, creates the `subscriptions` row with the type from metadata, status, price, quantity and trial end, and creates one `subscription_items` row per price; 3. if the user had a generic trial in `trial_ends_at`, the handler clears it. The docs state it plainly: using Checkout for subscriptions requires the `customer.subscription.created` webhook to be enabled. Without webhooks the customer pays and your app never learns about it. ## Designing the success page Because the webhook and the redirect race each other, the success page should not assume `subscribed()` is already true: - show a 'finishing setup' state and poll, or re-check on the next request; - never grant access from query parameters on the success URL, which anyone can type; - use the session ID template variable `{CHECKOUT_SESSION_ID}` if you need to look the session up. ## Checkout-specific limits - Stripe Checkout needs a trial end at least 48 hours away. Cashier raises a shorter trial to 48 hours and 10 seconds, so `trialDays(1)` becomes about two days. - Billing-cycle anchoring, proration behaviour and payment behaviour set on the builder have no effect in a Checkout session. - The subscription type travels in metadata, which is how the webhook knows to store `'default'` rather than a fallback name. ## When to prefer create() `create($paymentMethodId)` suits a custom payment form built with Stripe Elements. It returns the `Subscription` immediately, but a card that needs 3D Secure makes it throw `IncompletePayment`, which you must handle. Checkout moves that confirmation onto Stripe's page, which is why it is the default choice for a small SaaS. ## One-off purchases use the same mechanism Checkout is not only for subscriptions. `$user->checkout('price_episode_pack')` on the billable model creates a Checkout session in payment mode for a one-time price, and `checkoutCharge($amount, 'Custom jingle')` does the same for an ad-hoc amount. Guests without an account can use `Checkout::guest()`. In every case the same rule holds: the redirect back to your app is a user-experience event, while the authoritative record of payment arrives from Stripe, through a webhook or by retrieving the Checkout session by its ID. ## Interview traps - **"Store the plan on the user when they click subscribe."** Nothing is paid yet; wait for the subscription row. - **"`trialDays()` works the same in Checkout and `create()`."** Only Checkout enforces the 48-hour minimum. - **"`subscribed()` on the success page proves the webhook works."** It may pass or fail depending on timing; check Stripe's delivery log instead.
- In Laravel Cashier, a customer completes Checkout but the app still shows the pricing page. What do you check first?Whether `customer.subscription.created` reached `/stripe/webhook` and succeeded: the endpoint exists in Stripe, the URL is public, the request was not rejected by CSRF protection or a 403 signature failure, and the user's `stripe_id` matches the event's customer. Stripe's dashboard shows each delivery attempt and response code.
- Why might trialDays(1) on a Laravel Cashier Checkout subscription give the customer about two days?Stripe Checkout requires a trial end at least 48 hours in the future, because the session can stay open for 24 hours. Cashier's `checkout()` replaces any earlier trial end with now plus 48 hours and 10 seconds.
saying these in an interview costs you the question
- checkout() writes the subscriptions row before redirecting to Stripe.
- Reaching success_url proves the customer is now subscribed.
- Checkout subscriptions work without any webhook configured.
- A Checkout trial can be as short as one hour.
- You must call ->redirect() because a Checkout object cannot be returned.