skip to content

In Laravel Cashier, how does newSubscription(...)->checkout() start a subscription, and when does the local subscriptions row appear?

level: middleimportance: should knowfreq 30%

answer

  1. SubscriptionBuilder, then a hosted page
  2. Checkout is Responsable: return it
  3. customer.subscription.created creates the row
  4. success_url is not proof of payment
  5. Checkout trials need 48 hours

basics

~20 s

newSubscription('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
<?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

for a junior

Recall the chain newSubscription('default', $price)->checkout([...]) and that the customer pays on a Stripe-hosted page, then comes back to your success URL.

for a middle

Explain the builder options, what the Checkout object is, and that the subscriptions row is created by the customer.subscription.created webhook.

for a senior

Design for the webhook and redirect race, never trust success URL parameters, and know the Checkout limits on trials and billing anchors.

for a principal

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.