The Controller Disaster
When integrating Stripe or Razorpay into a Laravel backend, most developers take the path of least resistance. They put all the logic directly into a single controller. They check if the user is paying, charge the card, update the database, and send an email, all in one giant 150-line PHP method.
This works exactly once: on the happy path. But what happens if the user's bank requires 3D Secure authentication? What if the card is declined for insufficient funds? What if the webhook arrives before the frontend finishes processing?
Your giant controller collapses into an unmaintainable mess of if/else statements, and worse, users get charged without receiving access to the product.
Architecting the State Machine
Handling money requires absolute precision. To solve the complexity of failed payments and asynchronous webhooks, we completely decouple our payment logic from our HTTP controllers. We architect a Billing State Machine.
Instead of treating a "Subscription" as a simple boolean column (is_premium = true), we treat it as an isolated entity that transitions between strict states: pending, active, past_due, and canceled.
The Implementation Strategy
In Laravel, we manage these transitions using dedicated Action classes and Webhook handlers, never trusting the Flutter frontend to dictate the state of a payment.
- The Intent: The Flutter app requests to buy a subscription. The Laravel controller generates a Stripe SetupIntent, creates a
pendingsubscription record in the database, and returns the client secret to the phone. The controller's job is done. - The Processing: The Flutter app handles the actual credit card input directly with Stripe's servers to remain PCI compliant.
- The State Transition: We rely entirely on asynchronous webhooks. When Stripe successfully charges the card, they ping our secure Laravel webhook endpoint. An isolated background job intercepts this, finds the
pendingsubscription, and officially transitions its state toactive.
Conclusion
By enforcing a strict state machine, we guarantee that a user's account is only upgraded when the money actually clears the bank. We eliminate race conditions, handle 3D secure failures gracefully, and ensure our financial logic remains clean, testable, and completely decoupled from our UI.