VRPay in Laravel: Payment Status as a State Machine
A payment provider is not just redirect plus callback. The awkward part starts after that: when is an order safe to fulfill?
In this case, the scenario is a Laravel shop for reservations and vouchers. VRPay payments can succeed immediately, stay delayed, get rejected, or resolve only when the customer opens the status page again. That is why the flow works best as a small state machine around pending, paid, and declined. Fulfillment runs once, even if the status check sees the same success more than once.
Why Payment Providers Are Not Just Redirects
A VRPay integration has four moving parts.
First, the checkout prepares the order and the payment. Then a provider-hosted payment widget handles the payment input. After that, the customer returns with a provider resource path. Only then does the shop ask for the actual payment status.
That sounds linear. It is not. The return parameter can be missing. It can be invalid. The provider can send a pending result code even though the customer has just paid. A status request can fail briefly. A customer can reload the status page. And if fulfillment is attached to the wrong step, PDFs, emails, or external order handoff can run twice.
Declined payments are the easy case. The harder cases are payments that are neither clearly successful nor clearly rejected. They must not become errors just because they are not final yet.
The Flow in the Laravel Shop
The shop separates preparation, provider UI, and status confirmation.
CheckoutSummary::pay()validates terms acceptance and payment rules. ThenOrderService::create()creates the order with an open payment status.PaymentPay::initializePayment()prepares the VRPay checkout throughVRPayConnectorandPostCheckoutPrepare.- The Blade payment view loads VRPay’s
paymentWidgets.jsonly after a checkout ID exists. No prepared checkout, no widget. PaymentStatus::mount()receives the returningresourcePathand validates it before the shop sends any follow-up request.OrderService::checkPaymentStatus()stores the provider transaction ID and applies the result to the order.PaymentStatus::refreshPaymentStatus()keeps polling while the order is stillpendingand the retry window is still open.
The status page is not a thank-you page with a little extra logic. It is the controlled handover between the provider’s world and the shop’s world.
A State Machine Instead of Payment If-Else
The logic needs only a few states, but the transitions need to be strict.
| Status | Meaning | Next step |
|---|---|---|
pending | The payment exists, but is not finally confirmed or rejected yet. | Query the status again. |
paid | The provider returned a final success pattern. | Trigger fulfillment, then never do it again. |
declined | The provider returned a final decline pattern. | Show the error and allow a new payment attempt. |
OrderService::applyTransactionResult() turns this into a hard rule.
If an order is already paid, the method returns immediately. PDFs, the customer event, and the external handoff do not run twice.
When the shop recognises a success pattern in the result code, it moves the order to paid, stores the final transaction ID and payment date, generates PDFs, dispatches the customer event, and dispatches the order export.
When the shop recognises a decline pattern, it moves the order to declined.
Every other result stays pending. That includes real pending codes, delayed results, and provider-side technical errors. This is intentional. As long as there is no final answer, the process remains retryable.
The ID split matters: payment_transaction_id is the provider ID the shop can use for later re-queries. transaction_id is the final payment reference for the completed payment. Putting both into one field feels convenient at first, but it makes revisits, polling, and reporting messier.
Security at the Edges
The dangerous parts sit at the boundaries, where browser, provider, and shop meet.
PaymentStatus accepts the resourcePath only if it matches a strict internal VRPay path shape. Free-form URLs are not forwarded. Payment routes are throttled. An order can be viewed only by the owning logged-in user or by a guest whose session data and guest token match the order. The token comparison uses hash_equals semantics, so a simple string comparison does not leak timing information.
Logging stays narrow too. The shop logs only whitelisted status fields such as result code, description, payment type, and transaction reference. No raw provider responses. No customer data. No card or bank-account data.
The practical rule: fail closed at the edges, stay retryable inside. An invalid return parameter stops immediately. A temporary provider failure keeps the order open, so the next status check can still win.
Fulfillment Only After a Final Result
The shop generates vouchers, reservation documents, and external order handoff only after a final success. Not when the order is created. Not when the widget loads. Not merely because the customer returned from the provider.
That is the difference between “the customer entered the payment flow” and “the payment is confirmed”. Only the second state may trigger fulfillment.
Advance payment is a separate path. It bypasses the VRPay state machine with payment instructions and immediate further processing. For vouchers and reservations on the VRPay path, only the final provider result counts.
What to Take Into Your Laravel Project
- keep provider preparation separate from status confirmation;
- validate return parameters before sending follow-up requests;
- store the provider transaction id for revisits;
- make fulfillment idempotent;
- treat pending as a real state, not as an error;
- test success, pending, decline, missing parameter, invalid parameter, transient provider failure, and retry-window exhaustion.
If your checkout needs more than standard shop behavior, it needs clean Laravel application logic. That is what we build in Laravel projects. For a concrete reservation, voucher, and VRPay case, see the Vulkaneifel Therme project.
Frequently Asked Questions
When is a Laravel shop safe to fulfill a VRPay order?
Only after a final success. Not when the order is created, not when the payment widget loads, and not merely because the customer returned from the provider. Only the paid state may trigger fulfillment – vouchers, reservation documents, and external handoff. That is the difference between the customer entered the payment flow and the payment is confirmed; only the second state counts.
How do you prevent double fulfillment on payments?
Through idempotent fulfillment and a small state machine. The applyTransactionResult method returns immediately if the order is already paid – PDFs, the customer event, and the external handoff do not run again. So the status page can be loaded or polled several times and see the same success without anything being generated twice.
How should you handle a pending payment status?
As a real state, not as an error. As long as there is no final success or decline pattern – which includes real pending codes, delayed results, and transient provider-side technical errors – the order stays pending and the process remains retryable. Only a final pattern moves the order to paid or declined. Treating a not-yet-final payment as an error would be wrong.
How do you secure the VRPay return in Laravel?
By the rule: fail closed at the edges, stay retryable inside. The resourcePath is accepted only if it matches a strict internal VRPay path shape; free-form URLs are not forwarded. Payment routes are throttled, an order can be viewed only by the owning user or matching guest (token comparison via hash_equals against timing leaks), and only whitelisted status fields are logged – no raw responses, customer, or payment data.
Related Services
Related Projects
Sr. Full-Stack Developer
Frontend & Backend. Builds scalable web applications with a focus on clean architecture and maintainability.