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?
answer
- renewals and dunning happen inside Stripe
- cashier:webhook registers eight events
- STRIPE_WEBHOOK_SECRET turns on signature checks
- exempt stripe/* from CSRF
- WebhookReceived and WebhookHandled events
basics
~20 sRenewals, 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 sCashier'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 linephp artisan cashier:webhook --url "https://podcasts.example.com/stripe/webhook"go deeper
Recall that Stripe tells your app about payments through webhooks at /stripe/webhook, and that cashier:webhook helps register them.
Explain which events Cashier handles, what each does to the subscriptions table, and the setup steps: endpoint, signing secret and CSRF exemption.
Treat the local table as a webhook-fed cache, insist on signature verification, and debug missed or rejected deliveries from Stripe's delivery log.
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.