Renewals and dunning
Package-managed subscriptions renew through billing:process-recurring-charges, which runs every minute when billing.schedule.enabled is on. Provider-managed ones (Stripe Billing, Paddle) are renewed by the provider and skipped here — see Provider-managed subscriptions.
The renewal flow
Section titled “The renewal flow”sequenceDiagram
participant Cron as Scheduler (every minute)
participant Cmd as process-recurring-charges
participant Driver as Gateway driver
participant Bank as Payment gateway
participant Listener as Built-in listener
Cron->>Cmd: run
Note over Cmd: 1. cancels_at reached: canceled, SubscriptionCancelled<br/>2. skip if a renewal Payment is still pending<br/>3. skip until next_retry_at
Cmd->>Cmd: create pending Payment (payable = Subscription)
Cmd->>Driver: chargePaymentMethod(payment, default card)
Driver->>Bank: off-session charge
Bank-->>Driver: initiated
Bank->>Listener: webhook: PaymentSucceeded / PaymentFailed
alt paid
Note over Listener: active, period +1 interval,<br/>dunning reset, SubscriptionRenewed
else failed
Note over Listener: past_due, attempts +1, next_retry_at,<br/>SubscriptionPaymentFailed, or canceled when exhausted
end
Per run, the command:
- Finalizes due cancellations —
active/past_duerows whosecancels_athas passed becomecanceled(SubscriptionCancelled), so a period-end cancellation is never billed again. - Picks due subscriptions: status
activeorpast_due, a gateway set, not provider-managed,current_period_ends_at <= now(), andnext_retry_atempty or due. - For each, under a row lock:
- a renewal
Paymentof this subscription stillpending→ skip (no double charge until it resolves); - the amount is resolved — the price’s currency or a sibling price, or a conversion (Money and currencies) — and scaled by the pricing type;
- amount zero (metered with no usage, zero seats) → the period advances without a gateway call;
- no default card for this billable and gateway, or it has expired → treated as a declined renewal (dunning), no gateway call;
- otherwise a pending
Payment(payable = the subscription) is created and charged withchargeWithMethod(), with options fromRenewalChargeOptionsContract(Fiscal receipt items).
- a renewal
- If the charge can’t even be initiated (timeout, gateway 5xx, a resolver exception) the payment is written off as
failedandPaymentFailedfires — dunning starts. A pending row left behind would block the subscription’s renewals for good. If the bank did debit after all, its webhook flips the row topaidbefore the next retry.
The command only initiates; outcomes come back through the webhook pipeline and the built-in listener (HandleSubscriptionPaymentOutcome).
On success
Section titled “On success”PaymentSucceeded for a payment whose payable is a running package-managed subscription (incomplete, trialing, active, past_due):
- status
active, gateway stamped if it was null; current_period_ends_atadvanced by one interval from its previous value (from now if null). Month and year steps don’t overflow (Jan 31 + 1 month = Feb 28, and the anchor stays clamped);recurring_attempts,grace_ends_at,next_retry_atcleared;period_notices_sentcleared;- usage reset (metered price, or a price with
included_units); SubscriptionRenewed($subscription, $previousStatus).
previousStatus tells the three moments apart:
Event::listen(function (SubscriptionRenewed $event) { $organization = $event->subscription->billable;
match ($event->previousStatus) { SubscriptionStatus::Incomplete, SubscriptionStatus::Trialing => $organization->notify(new Welcome), SubscriptionStatus::PastDue => $organization->notify(new AccessRestored), default => $organization->notify(new RenewalReceipt), };});previousStatus is null only for provider-managed renewals where the package didn’t see the transition. A row your own code created as active and then charged reads as Active — send the welcome from where you created it.
Because the period advances from the scheduled end, days spent in past_due are never billed and never compensated: a period due January 1 that recovers on January 4 still renews February 1. Credit days by hand if you want to: $subscription->update(['current_period_ends_at' => $subscription->current_period_ends_at->addDays(3)]).
On failure: dunning
Section titled “On failure: dunning”PaymentFailed or PaymentCanceled for a renewal of an active/past_due subscription records a failure (Subscription::recordRenewalFailure()):
recurring_attempts+ 1;- when attempts reach
max_recurring_attempts, or the retry list is empty →canceled(SubscriptionCancelled); - otherwise
past_due,next_retry_at= now + the retry interval for this attempt,grace_ends_at=next_retry_at+grace_period_days, andSubscriptionPaymentFailedfires.
With the defaults (max_recurring_attempts 4, retry_intervals ['6 hours', '24 hours', '48 hours']): the renewal, then retries after 6 h, 24 h and 48 h, then cancelled. Per price: prices.retry_intervals.
Not dunned:
- a failure on an
incompleteortrialingrow — a failed checkout, not a failed renewal; canceled,ended,pausedrows (logged);- a subscription with
gatewaynull (paid by hand) — cancelled right away, there is no card to retry; - provider-managed rows.
Grace access
Section titled “Grace access”grace_ends_at is always past the next retry, so with grace_access on (default) access never lapses between two attempts.
'grace_access' => env('BILLING_GRACE_ACCESS', true),
// per price$plan->prices()->create([/* ... */ 'grace_access' => false]); // cut access on the first failed renewalgrace_access only gates isActive()/active(); attempts, retries and cancellation run identically either way. With it off, SubscriptionAccessSuspended fires once — on entering past_due — your cue for an “access suspended” notice, distinct from SubscriptionPaymentFailed, which fires on every failed attempt.
Updating the card
Section titled “Updating the card”Nothing special is required when a card fails — SubscriptionPaymentFailed is your cue to email a payment link. To take a new card, charge the subscription again with saveCard:
$payment = Payment::create([ 'gateway' => $subscription->gateway, 'amount' => $subscription->price->amount, 'currency' => $subscription->price->currency, 'payable_type' => $subscription->getMorphClass(), 'payable_id' => $subscription->id, 'billable_type' => $subscription->billable_type, 'billable_id' => $subscription->billable_id,]);
Billing::charge($payment, new ChargeOptions(saveCard: true));The payment reactivates the subscription and the new card becomes the default. Remove the old one with detachPaymentMethod() if you like.
Advance notice: SubscriptionPeriodEnding
Section titled “Advance notice: SubscriptionPeriodEnding”billing:send-period-notices (hourly) fires SubscriptionPeriodEnding($subscription, $notice, $willRenew) at each period_ending_notices interval before current_period_ends_at. Off by default:
'period_ending_notices' => ['3 days'], // per price: prices.period_ending_notices- Only
active, package-managed subscriptions are scanned. - Once per notice per period; several due at once → the closest fires; a successful renewal clears the markers.
$willRenewiscancels_at === null— “we’ll charge your card on the 14th” vs “your access ends on the 14th”. It states the package’s intent: the bank can still decline, and a subscription without a card renews only if someone pays.
Short-cycle billing
Section titled “Short-cycle billing”minute and hour intervals work out of the box — the renewal runs within a minute of the period end. Rethink the dunning defaults though: a 6 h / 24 h / 48 h ladder and a 3-day grace make no sense for an hourly rental ('retry_intervals' => ['5 minutes'], BILLING_MAX_RECURRING_ATTEMPTS=1 to end the rental on the first failure). See Use cases → Short-cycle billing.