skip to content

In Laravel Cashier, what does an IncompletePayment exception mean during a subscription or charge, and how do you handle SCA confirmation?

level: seniorimportance: nice to knowfreq 20%

answer

  1. 3D Secure means requires_action
  2. Laravel\Cashier\Exceptions\IncompletePayment
  3. $exception->payment->id
  4. route('cashier.payment', ...)
  5. incomplete and past_due are inactive by default

basics

~20 s

IncompletePayment means Stripe could not finish the payment without the customer, usually a 3D Secure step required by Strong Customer Authentication. Catch it, redirect to Cashier's cashier.payment route with the payment ID, and treat the subscription as incomplete until confirmed.

solid answer

~40 s

Under Strong Customer Authentication a bank can demand a 3D Secure step, leaving Stripe's payment intent in `requires_action`. Cashier methods that pay immediately, such as `create()` on the subscription builder, `charge()`, `invoice()`, `swapAndInvoice()` and `incrementAndInvoice()`, then throw `Laravel\Cashier\Exceptions\IncompletePayment`. Its `payment` property is a Cashier `Payment`; redirect to `route('cashier.payment', [$e->payment->id, 'redirect' => route('dashboard')])`, Cashier's hosted confirmation page, which only accepts a redirect to your own host. The subscription row already exists with status `incomplete`, or `past_due` after a swap, and both count as inactive unless you call `keepIncompleteSubscriptionsActive()` or `keepPastDueSubscriptionsActive()`. For renewals the customer is offline, so rely on `invoice.payment_action_required` with `CASHIER_PAYMENT_NOTIFICATION` or Stripe's own emails.

code

php · 20 lines
php
<?php

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use Laravel\Cashier\Exceptions\IncompletePayment;

Route::post('/plans/yearly', function (Request $request) {
    try {
        $request->user()
            ->newSubscription('default', 'price_podcast_yearly')
            ->create($request->input('payment_method'));
    } catch (IncompletePayment $exception) {
        return redirect()->route('cashier.payment', [
            $exception->payment->id,
            'redirect' => route('podcasts.index'),
        ]);
    }

    return redirect()->route('podcasts.index');
})->middleware('auth');

go deeper

for a junior

Recall that some cards need an extra 3D Secure step and that Cashier signals it with IncompletePayment, which you catch and redirect to Cashier's payment page.

for a middle

Explain which methods throw it, the incomplete and past_due statuses it leaves, and how the cashier.payment route and redirect parameter work.

for a senior

Cover off-session renewals, the default deactivation of incomplete and past_due subscriptions, and swap being blocked until payment is confirmed.

for a principal

Decide whether access is granted before payment confirmation, and how to balance conversion against revenue risk for customers who never authenticate.

## Where the exception comes from **Strong Customer Authentication (SCA)** is a European rule that lets a card issuer require the cardholder to confirm a payment, typically with **3D Secure** in a bank app. Stripe models this with a payment intent status such as `requires_action`. Your server cannot complete that step; the customer must. Cashier checks the payment after any method that charges immediately, and throws **`Laravel\Cashier\Exceptions\IncompletePayment`** when it is not complete. The factory methods on the exception name the three cases: 1. `paymentMethodRequired`: the card was declined and a new one is needed; 2. `requiresAction`: the customer must authenticate, the SCA case; 3. `requiresConfirmation`: the payment must still be confirmed. Methods that can throw it, per the docs: `charge()`, `invoiceFor()` and `invoice()` on the billable model; `create()` on the subscription builder; `swapAndInvoice()` and `incrementAndInvoice()` on subscriptions and subscription items. `swap()` can also end in it through the same payment-failure handling. **Checkout sessions do not throw it**, because the confirmation happens on Stripe's hosted page. ## What state you are left in The exception does not roll anything back. For `create()`, Cashier has already written the `subscriptions` row before checking the payment, so you have: - a row with `stripe_status` `incomplete`, or `past_due` when a swap needed a payment; - `hasIncompletePayment()` returning true on the subscription or `$user->hasIncompletePayment('default')`; - `active()` returning false, because Cashier deactivates `incomplete` and `past_due` subscriptions by default. Call `Cashier::keepIncompleteSubscriptionsActive()` or `Cashier::keepPastDueSubscriptionsActive()` in a service provider's `register()` only if the business accepts giving access before payment. An `incomplete` subscription also cannot be swapped: `swap()` throws `SubscriptionUpdateFailure` until the payment is confirmed. If the customer never confirms, Stripe expires it to `incomplete_expired`, and Cashier's webhook handler deletes the local row. ## Handling it on-session When the customer is in front of the screen, send them to **Cashier's payment page**, registered at `GET /stripe/payment/{id}` as `cashier.payment`: 1. catch `IncompletePayment`; 2. redirect to `route('cashier.payment', [$exception->payment->id, 'redirect' => route('podcasts.index')])`; 3. the page lets the customer re-enter the card and complete 3D Secure, then sends them to `redirect` with `success` and `message` query parameters. The route's `VerifyRedirectUrl` middleware rejects a `redirect` pointing at another host with a 403, so the page cannot be used as an open redirect. You can inspect the case first with `$exception->payment->requiresPaymentMethod()` or `requiresAction()` if you want a different message. ## Handling it off-session Renewals happen while the podcast host is asleep. If the bank demands authentication for the monthly charge: - Stripe sends `invoice.payment_action_required`; - if `CASHIER_PAYMENT_NOTIFICATION` names a notification class, Cashier's webhook handler notifies the billable user with a link to confirm, provided the model uses `Notifiable`; - alternatively, enable Stripe's own billing emails and leave `CASHIER_PAYMENT_NOTIFICATION` unset. For a subscription that still has an incomplete payment, the docs suggest linking to `route('cashier.payment', $subscription->latestPayment()->id)` from the billing page. ## Summary table | Situation | Where confirmation happens | What your code does | |---|---|---| | Checkout session | Stripe's hosted page | nothing extra | | `create()`, `charge()`, `swapAndInvoice()` on-session | Cashier's `cashier.payment` page | catch `IncompletePayment` and redirect | | Renewal needing authentication | email link | set `CASHIER_PAYMENT_NOTIFICATION` or Stripe emails | | Card declined outright | new card entry | `requiresPaymentMethod()` is true; collect a new card | ## Why create() leaves an incomplete subscription Since Cashier 14, new subscriptions and updates use Stripe's **`default_incomplete`** payment behaviour: Stripe creates the subscription with an unpaid first invoice and waits for the payment to be confirmed, rather than failing the request. That is exactly why `create()` can store an `incomplete` row and then throw. The builder and subscription offer `allowPaymentFailures()`, `pendingIfPaymentFails()` and `errorIfPaymentFails()` to choose another behaviour; `pendingIfPaymentFails()` on a swap keeps the old price until the new payment succeeds.

  • In Laravel Cashier, a host's subscription is incomplete and they try to switch to the yearly plan. What happens?
    `swap()` calls `guardAgainstIncomplete()` first and throws `SubscriptionUpdateFailure` for an `incomplete` subscription. The host must confirm the outstanding payment on the `cashier.payment` page before the plan can change.
  • Why can't Laravel Cashier's IncompletePayment handling cover monthly renewals?
    Renewals are charged by Stripe on its schedule, with no request from the customer in progress, so there is nothing to catch and no browser to redirect. Stripe reports the need for authentication with `invoice.payment_action_required`, and Cashier can notify the user through `CASHIER_PAYMENT_NOTIFICATION`, or Stripe can email them.

saying these in an interview costs you the question

  • IncompletePayment means the card was simply declined and nothing was created.
  • The subscription row is only written once the payment succeeds.
  • incomplete subscriptions count as active by default.
  • Stripe Checkout sessions throw IncompletePayment too.
  • The redirect parameter on cashier.payment may point at any external site.