skip to content

In Laravel Cashier, how do swap(), cancel(), cancelNow() and resume() change a subscription, and what do onTrial() and onGracePeriod() report?

level: middleimportance: should knowfreq 32%

answer

  1. ends_at is the cancel marker
  2. cancel() waits for the period end
  3. resume() only inside the grace period
  4. swap() prorates unless noProrate()
  5. valid = active or trial or grace

basics

~20 s

swap() moves to new prices, prorating by default. cancel() sets ends_at to the period end, leaving a grace period; cancelNow() ends it at once; resume() works only inside the grace period. onTrial() and onGracePeriod() compare trial_ends_at and ends_at with now.

solid answer

~30 s

On a Cashier `Subscription`, `swap('price_podcast_yearly')` updates the Stripe subscription's items and, by default, lets Stripe prorate; `noProrate()` or `swapAndInvoice()` change that, and an `incomplete` subscription cannot be swapped. `cancel()` sets `cancel_at_period_end` in Stripe and writes `ends_at` as the trial end or the current period end, so `onGracePeriod()` is true and `subscribed()` still returns true until then. `cancelNow()` cancels in Stripe at once and marks the row `canceled` with `ends_at` now. `resume()` clears the cancel, but throws `LogicException` outside the grace period. `onTrial()` means `trial_ends_at` is in the future; `onGracePeriod()` means `ends_at` is.

code

php · 15 lines
php
<?php

$subscription = $request->user()->subscription('default');

// Upgrade to the yearly plan, invoicing the difference now
$subscription->swapAndInvoice('price_podcast_yearly');

// Cancel at the end of the paid period
$subscription->cancel();
$subscription->onGracePeriod(); // true until ends_at

// Change of heart before ends_at
if ($subscription->onGracePeriod()) {
    $subscription->resume();
}

go deeper

for a junior

Recall that cancel() keeps access until the period ends, cancelNow() stops it at once, and resume() undoes a cancel during the grace period.

for a middle

Explain how trial_ends_at and ends_at drive onTrial, onGracePeriod, ended and valid, and how swap handles proration and incomplete subscriptions.

for a senior

Anticipate edge cases: cancelling during a trial, swapping during a grace period, resume after cancelNow, and changes made in Stripe arriving by webhook.

for a principal

Set product rules for downgrades, refunds and win-backs, and map them onto proration, grace periods and immediate cancellation without surprising customers.

## Two timestamps drive the state A Cashier `subscriptions` row carries `stripe_status` from Stripe plus two timestamps that Cashier's helper methods read: - **`trial_ends_at`**: set while a trial runs; - **`ends_at`**: null for a live subscription, set once it is cancelled. | Method | True when | |---|---| | `onTrial()` | `trial_ends_at` is in the future | | `canceled()` | `ends_at` is not null | | `onGracePeriod()` | `ends_at` is in the future | | `ended()` | cancelled and no longer in the grace period | | `active()` | not ended, and the status is not `incomplete_expired` or `unpaid`, nor `past_due` or `incomplete` while Cashier deactivates those | | `valid()` | `active()`, `onTrial()` or `onGracePeriod()` | `$user->subscribed()` is `valid()` on the default subscription, which is why a podcast host who cancelled today keeps uploading until the paid month ends. ## swap() `$user->subscription('default')->swap('price_podcast_yearly')` moves the subscription to new prices: 1. it refuses an `incomplete` subscription with `SubscriptionUpdateFailure`; 2. it updates the Stripe subscription's items, with proration behaviour `create_prorations` by default; 3. it saves the new status, price and quantity locally, clears `ends_at`, and syncs the items; 4. if the new price needs a payment the card cannot complete, it throws `IncompletePayment`. Variations: `noProrate()->swap(...)` changes price without proration credit or charge; `swapAndInvoice(...)` invoices the difference immediately instead of on the next bill. A swap on a cancelled subscription in its grace period also un-cancels it: Cashier sends `cancel_at_period_end` false to Stripe (unless the payment behaviour is `pending_if_incomplete`) and resets `ends_at` locally. ## cancel(), cancelAt() and cancelNow() - **`cancel()`** asks Stripe to cancel at period end (`cancel_at_period_end`), then writes `ends_at`: the trial end if the customer is on trial, otherwise the current period end. The subscription is now in its **grace period**. - **`cancelAt($date)`** schedules cancellation for a specific moment and stores it in `ends_at`. - **`cancelNow()`** cancels in Stripe immediately and marks the row `canceled` with `ends_at` set to now, so there is no grace period. **`cancelNowAndInvoice()`** also bills pending usage and prorations. The docs advise cancelling subscriptions before deleting a user, so Stripe does not keep charging a customer you have removed. ## resume() `resume()` reverses a `cancel()`: 1. it throws `LogicException` if the subscription is not on its grace period; 2. it sets `cancel_at_period_end` back to false in Stripe, keeping any remaining trial; 3. it clears `ends_at` and saves the new status. The customer is not billed on resume; billing continues on the original cycle. After `cancelNow()` or after the grace period ends there is nothing to resume, so the customer needs a new subscription. ## Trials A **subscription trial** comes from `trialDays()` or `trialUntil()` on the builder and lives in the subscription's `trial_ends_at`. A **generic trial** is `trial_ends_at` on the user, set at registration without any Stripe subscription. `$user->onTrial()` with no arguments checks the generic trial first, then the default subscription; `onGenericTrial()` checks only the user column. Passing a type, `onTrial('default')`, ignores the generic trial. ## Local writes and webhooks Each of these methods calls Stripe and then updates the local row, so your UI reflects the change at once. Changes made elsewhere, such as a cancel in the Stripe customer portal or a failed renewal, reach the row only through the webhook handlers, which is why production apps keep them wired up. ## Querying by state Every state check also exists as a query scope on the `Subscription` model, which is how admin dashboards and scheduled reports find customers without looping in PHP: - `Subscription::query()->onTrial()` and `->notOnTrial()`; - `->onGracePeriod()` and `->notOnGracePeriod()`; - `->canceled()`, `->notCanceled()` and `->ended()`; - `->active()`, `->pastDue()`, `->incomplete()` and `->recurring()`. A weekly job that emails podcast hosts whose grace period ends in three days is a `whereBetween('ends_at', ...)` on top of `onGracePeriod()`. Because these scopes read local columns, their accuracy depends on the webhook handlers keeping `stripe_status`, `trial_ends_at` and `ends_at` current.

  • In Laravel Cashier, a customer cancels during a 14-day trial. When does ends_at land, and can they still use the product?
    `cancel()` sets `ends_at` to the trial end when the subscription is on trial. Until then `onGracePeriod()` and `onTrial()` are true, so `subscribed()` stays true; after it, the subscription has ended and no charge is made.
  • Why would you call noProrate() before swap() in Laravel Cashier?
    By default Stripe prorates a price change, crediting unused time on the old price and charging for the new one. `noProrate()` sets the proration behaviour to `none`, so the new price simply applies from the next billing period. It has no effect on `swapAndInvoice()`, which always issues an invoice.

saying these in an interview costs you the question

  • cancel() stops access immediately.
  • resume() works any time after a cancel, even after ends_at.
  • subscribed() returns false during the grace period.
  • swap() never prorates unless you ask it to.
  • cancelNow() leaves a grace period until the period end.