Software

Cashier locks customers out on day 0 of the retry window

By · Sat Oct 24 2026 · 9 min read · 0 views

View as a Web Story

Software#stripe#Laravel#cashier#subscriptions#dunning#past due

Laravel Cashier failed-payment access compared: the default blocks a past_due customer from day 0 while a grace-period ladder keeps access for 14 days

Laravel Cashier locks customers out on day 0 of Stripe's retry window. The first failed renewal moves a subscription to past_due, and Cashier's valid() returns false from that moment. Stripe's recommended retry setting keeps trying for up to 2 weeks. A card that fails on Monday and recovers on Thursday is blocked for three days in between.

The fix is a short access ladder: full access with a banner for 7 days, read-only until day 14, then blocked. This post shows the ladder, the webhook listener that feeds it, and a table of what Cashier 16.9.0 does for every Stripe status. It is the fifth post in a series on billing and tenant isolation for a solo Laravel and PostgreSQL SaaS.

What is dunning, and what does Stripe do after a failed renewal?

Dunning is the process of retrying a failed subscription payment and asking the customer to fix it. Stripe runs the retries for you. Smart Retries is Stripe's feature that picks retry times for a failed payment. The Stripe Smart Retries documentation says the recommended default is 8 tries within 2 weeks, and the window can also be 1 week, 3 weeks, 1 month, or 2 months. A custom schedule allows up to three retries.

Stripe does not retry every failure. The same page lists cases with no retry, such as when no payment method is available or when the issuer returns a hard decline code. The status of the subscription during the window is past_due. The subscription overview says past_due does not guarantee another payment attempt, so do not assume a retry is coming.

What happens when the window ends is your setting. In the Stripe dashboard, you choose one of four outcomes.

Outcome after final retry Resulting status Invoices afterward
Cancel the subscription canceled None
Mark as unpaid unpaid Generated as drafts, not charged
Leave past due past_due Generated and charged per retry settings
Pause the subscription paused None until resumed

Stripe's overview says to revoke access when a subscription is unpaid, because Stripe has already retried the payment while it was past_due. That is the end of the ladder. The question this post answers is what to do before the end.

How I tested Cashier's failed-payment handling

The methodology was a scripted lab on Laravel 13.38, Cashier 16.9.0, and PostgreSQL 18.1. I sent signed synthetic Stripe events through Cashier's real webhook route and watched the local subscriptions row. I evaluated Cashier's own predicates on subscription models for every Stripe status. I then ran an access policy over a 21-day timeline with time travel.

The events were synthetic and no live Stripe account was involved. The lab shows how Cashier and the policy react to the events, not how often real payments recover. I found no recovery-rate figures in the Stripe pages I read, so this post makes no claim about recovery. As a fact-check, I compared each Stripe statement with the linked docs pages on October 24, 2026.

What does Cashier do when a renewal payment fails?

Nothing, on its own. Cashier's webhook controller has no handler for invoice.payment_failed. I sent that event to /stripe/webhook. Cashier answered 200 and dispatched its WebhookReceived event, and the local subscription stayed active. The row changes to past_due only when the later customer.subscription.updated event arrives.

Cashier 16.9.0 ships handlers for subscription created, updated and deleted, customer updated and deleted, an automatically updated payment method, invoice.payment_action_required, and invoice.payment_succeeded. A failed renewal reaches your code only as a WebhookReceived event that you must listen for. The Cashier webhook documentation describes that extension point.

Advertisement

This matters for emails. Stripe can send its own reminders if you tick "Send emails when card payments fail" in the dashboard. If you want an email in your own voice, with a link to update the card, you build it from this event.

Which statuses does Cashier treat as active?

Cashier treats past_due as inactive by default, and it treats paused as active. I built a subscription model for each Stripe status and called active(), valid() and pastDue(). The table shows the default configuration, and the second column of results shows Cashier::keepPastDueSubscriptionsActive().

Stripe status valid(), default valid(), keepPastDueSubscriptionsActive()
trialing yes yes
active yes yes
past_due no yes
unpaid no no
incomplete no no
incomplete_expired no no
paused yes yes
canceled with no ends_at yes yes

Cashier's default valid() is false for past_due from the first failed payment, while the two-week retry window is still open

Two rows deserve attention. First, paused passes valid(). If you choose "Pause the subscription" as your failed-payment outcome, Cashier keeps granting access. Block it yourself. Second, a row marked canceled with an empty ends_at also passes. Real Stripe cancellation events carry canceled_at, and Cashier then fills ends_at, so I saw this only on a hand-built row. It shows that Cashier decides access from ends_at, not from the status text, so a missed cancellation webhook leaves a canceled customer valid.

Why is a day-0 lockout a problem?

The default is safe for revenue and hard on customers. In the timeline test, valid() was false at day 0, 3, 6, 7, 10, 13, 14, 15 and 21. Stripe's recommended retry window runs through about day 14, so the customer is blocked for the whole period in which the payment can still succeed.

The bottom line is a mismatch between two systems. Stripe says "we are still trying." Cashier says "no access." Common causes of a failed renewal are an expired card, a temporary bank hold, and a card that needs a new authentication. These often clear within days. The customer who fixes the card on day 3 spends those days locked out of their work.

The opposite setting is no better. keepPastDueSubscriptionsActive() gives full access for the entire retry window and for as long as you choose to leave the subscription past due. A customer who never intends to pay keeps the product. You want a policy in the middle.

What access ladder works for a solo SaaS?

A grace period is the time you keep serving a customer after a payment fails, before you restrict access. Use three levels: full access with a banner, read-only, and blocked. The windows below match Stripe's recommended two-week retry period. Adjust them to your own retry setting. For example, a one-week Smart Retries window suggests a banner for 3 days and read-only until day 7. Such a shorter ladder keeps the block date close to Stripe's last attempt.

Days since first failure Access What the customer sees
0 to 6 Full, with a banner "Your payment failed. Update your card."
7 to 13 Read-only Can view and export data, cannot create
14 and later Blocked Billing page only

The policy class is small. It reads one extra column, past_due_since, and falls back to Cashier's own checks for every other status.

public static function level(Subscription $s, ?CarbonInterface $now = null): string
{
    $now ??= now();

    if ($s->stripe_status === 'past_due') {
        $since = $s->past_due_since ? Carbon::parse($s->past_due_since) : $now;
        $days  = $since->diffInDays($now);

        return $days < 7 ? 'full+banner' : ($days < 14 ? 'read-only' : 'blocked');
    }

    if (in_array($s->stripe_status, ['unpaid', 'incomplete', 'incomplete_expired', 'paused'], true)) {
        return 'blocked';   // paused passes Cashier's valid(), so block it here
    }

    return $s->valid() ? 'full' : 'blocked';
}

Running it over a past-due subscription gave full access with a banner on days 0, 3 and 6, read-only on days 7, 10 and 13, and blocked on days 14, 15 and 21. Cashier's valid() said no for every one of those days.

How do you record when the failure started?

Listen for Cashier's WebhookHandled event and set past_due_since from the event's own created time. Use the event time, not the clock at processing time, so a delayed webhook does not shorten the grace period. Set it only if it is empty, so repeated past-due updates do not reset the clock.

Event::listen(WebhookHandled::class, function ($e) {
    $p = $e->payload;
    if ($p['type'] !== 'customer.subscription.updated') return;

    $o = $p['data']['object'];
    $q = DB::table('subscriptions')->where('stripe_id', $o['id']);

    if ($o['status'] === 'past_due') {
        $q->whereNull('past_due_since')
          ->update(['past_due_since' => Carbon::createFromTimestamp($p['created'])]);
    } elseif ($o['status'] === 'active') {
        $q->update(['past_due_since' => null]);
    }
});

I replayed a sequence through Cashier's real route to check it. A past-due event on day 0 set the timestamp. A second past-due event on day 3 left it unchanged. An active event on day 5 cleared it. A later failure, then an unpaid event, left the status unpaid with the failure time kept. A cancellation event with canceled_at set ends_at on the row.

What should the dunning email say?

Send a short email on the first invoice.payment_failed event, with a link to update the card, and another near the end of the grace period. The event carries attempt_count, so you can tell the first failure from a later one. Stripe's docs note that next_payment_attempt may be missing from this event when automations are on, so do not depend on it.

For the card-update link, Cashier can send the customer to Stripe's billing portal with $user->redirectToBillingPortal(). The Cashier billing portal documentation shows the setup. Stripe's overview says to update the payment method on the field where the previous payment failed, because retries try the subscription's default method first.

Protect the email from duplicates. A retried webhook would send the same email twice. Use the inbox table from the earlier post on webhook idempotency, and send the email inside the transaction that claims the event, or queue it with a job keyed on the event id.

How should hard declines change the plan?

A hard decline means Stripe will not retry, so waiting 14 days gains nothing. Stripe's docs list a hard decline code among the cases with no retries. For those failures, email at once and shorten the ladder, because no payment is coming without the customer's action. Check the failure reason on the invoice or payment intent and branch on it.

I did not test this branch, since the lab had no real decline codes. It follows from the Stripe documentation. Treat it as a design suggestion and verify it with Stripe's test cards in a sandbox.

How do you test the ladder?

Freeze time with Carbon and assert the level on each boundary day. The boundaries are where policies break, so test days 6, 7, 13 and 14.

it('walks the access ladder', function (int $day, string $level) {
    $sub = new Subscription(['stripe_status' => 'past_due', 'past_due_since' => $t0 = now()]);
    $this->travelTo($t0->copy()->addDays($day));

    expect(Access::level($sub))->toBe($level);
})->with([[0, 'full+banner'], [6, 'full+banner'], [7, 'read-only'], [13, 'read-only'], [14, 'blocked']]);

What should you change first?

Change three things, in this order. First, replace direct valid() checks in your middleware with the ladder. Second, set Stripe's final outcome to cancel or unpaid, so the end of the window matches day 14. Third, block paused explicitly if you ever use it.

The numbers here come from a synthetic lab, so use your own retry window and your own grace days. The statuses and predicates are stable Cashier behavior worth a test in your suite. The day counts are policy, and you should choose them based on how your customers pay.

Advertisement

FAQ

Does Laravel Cashier handle failed payments automatically?

Only partly. Cashier 16.9.0 has no handler for `invoice.payment_failed`, so it answers 200 and does nothing. The local status changes when the later `customer.subscription.updated` event arrives. Listen for `WebhookReceived` if you want to email the customer.

Is a past_due subscription valid in Cashier?

No, by default. `valid()` and `active()` are false for `past_due`, so the customer is blocked from the first failed renewal. `Cashier::keepPastDueSubscriptionsActive()` makes `valid()` true for `past_due` for as long as the status lasts.

How long does Stripe retry a failed subscription payment?

Stripe's recommended Smart Retries default is 8 tries within 2 weeks, and you can choose 1 week, 3 weeks, 1 month or 2 months. A custom schedule allows up to three retries. Stripe does not retry hard declines or cases with no payment method.

What happens to a Stripe subscription after the last retry fails?

It follows your dashboard setting: the subscription is canceled, marked `unpaid`, left `past_due`, or paused. Stripe's overview says to revoke access when the status is `unpaid`. Cashier treats `paused` as valid, so block that status yourself.

How do I give customers a grace period with Cashier?

Store the time of the first past-due event in a `past_due_since` column, set from the event's `created` time and never reset by repeats. Then compute access from the days elapsed, such as full for 7 days, read-only until day 14, then blocked.

Comments

Loading…

Sign in to join the conversation.

Related posts