skip to content

In a Laravel Cashier app, why are Stripe webhooks the source of truth for subscription state, and how do you set up /stripe/webhook safely?

level: seniorimportance: should knowfreq 34%

answer

  1. renewals and dunning happen inside Stripe
  2. cashier:webhook registers eight events
  3. STRIPE_WEBHOOK_SECRET turns on signature checks
  4. exempt stripe/* from CSRF
  5. WebhookReceived and WebhookHandled events

basics

~20 s

Renewals, failed payments, portal cancellations and Checkout completions all happen inside Stripe, so only webhooks tell Cashier about them. Register /stripe/webhook with cashier:webhook, set STRIPE_WEBHOOK_SECRET so signatures are verified, and exempt the path from CSRF.

solid answer

~40 s

Cashier's `subscriptions` table is a local copy of Stripe's state. Your code changes it when you call `swap()` or `cancel()`, but most changes start in Stripe: renewals, cards failing into `past_due`, dunning cancellations, customer-portal edits and Checkout completions. Cashier's `WebhookController` at `POST /stripe/webhook` (route `cashier.webhook`, prefix `CASHIER_PATH`, default `stripe`) applies them: `customer.subscription.created`, `updated` and `deleted` create, sync or cancel rows. `php artisan cashier:webhook` registers the endpoint with Cashier's eight default events. Set `STRIPE_WEBHOOK_SECRET`: the controller only adds `VerifyWebhookSignature` when it is set, which rejects bad signatures with a 403 and uses a 300-second tolerance. The route must also be exempt from CSRF protection. Extra handling goes in listeners for `WebhookReceived` or `WebhookHandled`.

code

bash · 1 line
bash
php artisan cashier:webhook --url "https://podcasts.example.com/stripe/webhook"

go deeper

for a junior

Recall that Stripe tells your app about payments through webhooks at /stripe/webhook, and that cashier:webhook helps register them.

for a middle

Explain which events Cashier handles, what each does to the subscriptions table, and the setup steps: endpoint, signing secret and CSRF exemption.

for a senior

Treat the local table as a webhook-fed cache, insist on signature verification, and debug missed or rejected deliveries from Stripe's delivery log.

for a principal

Decide how billing state failures are detected and reconciled, and which product decisions may rely on the local copy versus a live Stripe check.

## Why the local table cannot be trusted alone Cashier keeps a `subscriptions` row per subscription, but **Stripe owns the subscription**. Many state changes never pass through your code: - a monthly renewal succeeds or fails, moving the status to `active` or `past_due`; - Stripe's retry schedule gives up and cancels the subscription; - the customer cancels or changes plan in the Stripe customer portal; - someone on your team refunds or cancels in the Stripe dashboard; - a Checkout session completes and creates the subscription. Stripe announces each of these as a **webhook event**, an HTTP POST to your app. If your app misses them, a podcast host whose card failed keeps publishing, and one who paid through Checkout never gets access. That is why interviewers call webhooks the source of truth: the row is a cache, and webhooks keep it honest. ## What Cashier registers and handles Cashier's service provider registers two routes under the `cashier.path` prefix, `stripe` by default (`CASHIER_PATH`): 1. `POST /stripe/webhook`, named `cashier.webhook`, handled by `WebhookController@handleWebhook`; 2. `GET /stripe/payment/{id}`, named `cashier.payment`, the payment confirmation page. The controller turns the event type into a method name, `customer.subscription.updated` becoming `handleCustomerSubscriptionUpdated`, and answers **200** after handling, or an empty 200 for types it does not handle. | Stripe event | Cashier's handler does | |---|---| | `customer.subscription.created` | creates the subscription and item rows if missing, clears a generic trial | | `customer.subscription.updated` | syncs status, price, quantity, trial end, `ends_at` and items; deletes the row on `incomplete_expired` | | `customer.subscription.deleted` | marks the row `canceled` with `ends_at` now | | `customer.updated` / `customer.deleted` | syncs the default payment method, or cancels subscriptions and clears Stripe fields | | `payment_method.automatically_updated` | refreshes the stored card details | | `invoice.payment_action_required` | notifies the customer if `CASHIER_PAYMENT_NOTIFICATION` is set | | `invoice.payment_succeeded` | tidies metadata for on-session Checkout payments | ## Setting the endpoint up 1. Run `php artisan cashier:webhook`. It calls Stripe's API to create an endpoint for `route('cashier.webhook')`, built from `APP_URL`, subscribed to the eight default events above (or `cashier.webhook.events`). `--url` overrides the URL, `--api-version` the Stripe API version, and `--disabled` creates it switched off. 2. Copy the endpoint's signing secret from Stripe into **`STRIPE_WEBHOOK_SECRET`**. 3. Exempt `stripe/*` from Laravel's CSRF protection, because Stripe cannot send a CSRF token; the CSRF leaf covers how. 4. For local development, the Stripe CLI can forward events to your machine. ## Signature verification is opt-in by configuration The controller's constructor adds **`VerifyWebhookSignature`** only when `cashier.webhook.secret` is set. That middleware checks the `Stripe-Signature` header against the raw body with the secret and a timestamp tolerance of `STRIPE_WEBHOOK_TOLERANCE`, 300 seconds by default, and throws `AccessDeniedHttpException`, a 403, on failure. The consequence is sharp: **if `STRIPE_WEBHOOK_SECRET` is missing in production, anyone who can reach `/stripe/webhook` can post a forged `customer.subscription.updated`** and change a subscription's local state. Treat the secret as mandatory configuration. ## Adding your own behaviour Rather than editing the controller, listen for Cashier's events. `WebhookReceived` is dispatched for every event before handling; `WebhookHandled` only after Cashier handled a known type. Both carry the full payload. A podcast host's welcome email after a first successful invoice, for example, belongs in a listener for `invoice.payment_succeeded`. Stripe may deliver an event more than once, so listeners with side effects must tolerate repeats; the idempotency pattern itself belongs to the distributed-systems topic. ## Debugging checklist - Stripe's dashboard lists each delivery with your response code; - a 419 means CSRF protection is still applied to the route; - a 403 means the signing secret is wrong or belongs to another endpoint; - a 200 with no change usually means no user has the event's `stripe_id`. ## Keeping API versions aligned Stripe shapes each webhook payload according to the endpoint's API version. Cashier's handlers expect the version Cashier itself speaks, which `cashier:webhook` uses by default. When upgrading Cashier across a Stripe API change, the upgrade guide suggests creating a new endpoint on the new version with `--disabled`, deploying, then switching endpoints, so payloads never arrive in a shape the handlers do not expect.

  • In Laravel Cashier, what happens to webhook requests when STRIPE_WEBHOOK_SECRET is empty?
    The `WebhookController` constructor only registers `VerifyWebhookSignature` when `cashier.webhook.secret` is set, so with no secret every POST to `/stripe/webhook` is processed unverified. Anyone who can reach the URL could forge subscription events, so production must always set the secret.
  • Stripe marks a podcast host's subscription past_due after a failed renewal. How does that reach Laravel Cashier's subscribed() check?
    Stripe sends `customer.subscription.updated`; Cashier's handler writes `stripe_status = past_due`. Because Cashier deactivates past-due subscriptions by default, `active()` becomes false, and unless the host is on trial or in a grace period, `subscribed()` returns false. Without the webhook the local row would still say active.

saying these in an interview costs you the question

  • Cashier polls Stripe on every request, so webhooks are optional.
  • Cashier always verifies webhook signatures, even without a secret configured.
  • Stripe webhooks can send a CSRF token, so no exemption is needed.
  • Custom Stripe handling requires copying and editing Cashier's controller.
  • A 200 response from /stripe/webhook proves the subscription was updated.