Stripe in Laravel. Model the Payment, Not the API Call.
A payment is a state machine with two money-moving events, not a boolean on an order. What that means for your schema, where Cashier stops helping, and the columns you need on day one.
Most Stripe integrations start with an SDK call and a column called paid. They
work for about four months, which is roughly how long it takes for the first
partial refund, the first disputed charge, or the first order where the
customer's bank asked for authentication and never came back.
The problem is never the API. It is that a payment was modelled as something that happened, when it is something that is still happening.
The thing you are integrating is a state machine
A PaymentIntent moves through states: it needs a payment method, it needs an action from the customer, it is processing, it succeeded, it was cancelled. Several of those transitions are driven by a bank rather than by your code, and some of them take minutes.
That has one direct consequence for your schema, and it is the whole article in
one sentence: your payment row holds a status, not a flag. If the column is
a boolean, then "the customer is on the 3D Secure screen right now" and "the
bank declined it" and "the money is authorised but not taken" all collapse into
false, and your support team cannot tell them apart.
Model the states you actually act on. Most businesses need five or six: requires action, processing, authorised, captured, failed, cancelled. Give it a real enum, and make the transitions explicit - the point of a status column is that illegal moves are rejected, not that a string gets overwritten.
Authorise and capture are two events
A card payment can reserve money without taking it. The authorisation holds
funds for a few days, and the capture is the separate act of actually collecting
them. Stripe exposes this as capture_method: manual.
Anybody who ships physical goods needs this, because taking money for something you have not sent yet is a refund waiting to happen, and in some jurisdictions it is a regulatory problem too. Anybody who sells a service with a deposit needs it. Anybody doing marketplace work needs it.
The schema consequence is that the authorised amount and the captured amount are
different columns, and they are frequently different numbers. You authorise the
full basket, then one item is out of stock, then you capture less. An order
model with one amount cannot express that, and bolting it on later means a
migration across every historical row while the finance team waits.
Also: an authorisation expires. If nothing captures it within the window, the hold drops and the money is gone from your reach. That is a scheduled job checking for authorisations approaching expiry, and it is not something Stripe reminds you about.
Saving a card is not saving a card
When a customer ticks "remember my card", you are not storing anything. You are creating a record of permission, and the permission has terms: what you may charge for, whether the customer has to be present, and whether their bank will want authentication again.
Stripe splits this into SetupIntent for collecting the permission and
off_session charges for using it. The distinction matters because an
off-session charge can fail with a request for authentication, and there is no
customer there to authenticate. Your code has to handle a payment that failed in
a way that is nobody's fault and is fixed by emailing the customer a link.
This is also where the European rules land. Under SCA, an unattended charge needs to fall into an exemption or carry a prior agreement, which in practice means the mandate has to have been set up correctly at the time the card was saved. Getting that wrong is invisible until the failure rate on your renewals is fifteen percent and nobody knows why.
Where Cashier stops
Cashier is good software and it is a subscription library. If you are selling plans with monthly and annual options, it carries the lifecycle, the proration arithmetic, the grace periods and the invoice records, and writing that yourself is a waste of a month.
It does not cover one-off payments with manual capture, marketplace splits, multi-party settlement, or a checkout where the amount is computed from a basket that changes. Those are the SDK directly, and that is expected rather than a failure of the library.
The mistake worth avoiding is treating Cashier's tables as your payment model. They describe subscriptions. Your orders, your captures, your refunds and your fees are yours, and they need to exist whether or not a subscription is involved.
The row you actually need
Minimum, for each payment attempt:
- Your own identifier, and the provider's identifier, indexed.
- Status, as an enum, with the last transition timestamp.
- Amount authorised, amount captured, amount refunded - three integers in the currency's minor unit, as money should always be stored.
- Currency code, separately.
- The fee, once you know it, because your revenue is not what the customer paid.
- The idempotency key you sent.
- A foreign key to whatever this pays for.
The last two do more work than they look. The idempotency key is what stops a retried job charging twice, and the queue underneath you will retry. Stripe accepts the key on every mutating request and returns the original response rather than charging again, but only if you send one, and only if it is derived from the attempt rather than generated fresh.
What to build first
Build the status machine and the webhook receiver before the checkout screen. The screen is an afternoon; the states are the system. A checkout that looks perfect and reports success from the redirect is the version that silently loses orders, because the redirect is not what tells you a payment succeeded.
Then reconcile. Once a day, ask Stripe for everything that changed and compare it against your rows. Not because the webhooks are unreliable, but because you want to know when something drifted, and the only alternative is learning about it from a customer.
We do this work as part of ecommerce builds and on its own, and the first question we ask is always the same one: authorise and capture together, or separately? The answer changes the schema, and it is easier to answer in week one than in month six.
