E-commerce 10 min read
How to integrate a payment gateway into a Laravel app
How to structure payments in a Laravel application, from payment records and hosted checkout to webhooks, refunds and tests.
On this page
A reliable Laravel payment gateway integration starts with your own payment records, sends customers to a secure payment page or form provided by the gateway, and treats the gateway's webhook as the final word on whether money was received. Around that core you need protection against double charges, a clear refund process and tests that run without touching real money. This guide walks through each part with short Laravel examples.
A payment gateway is the service that takes card or wallet payments on your behalf and sends the money to your account. Laravel is the PHP framework many business applications are built on. The examples below are gateway-neutral: field names and endpoints are placeholders, so always follow your gateway's own documentation. If you need help with an online store or checkout, our e-commerce service page explains how we work.
Designing payment records before a Laravel payment gateway integration
The most common mistake is storing a single "paid" flag on the order. Payments have their own life: they can be started, abandoned, completed, partly refunded or disputed. Give them their own table.
- One order, many payments. A customer may try twice, or pay part now and part later. A separate
paymentstable handles this cleanly. - Store amounts in minor units. Minor units are the smallest unit of a currency, such as cents. Storing whole numbers avoids rounding errors that come with decimals.
- Keep the gateway's reference. Every gateway returns an ID for the payment or checkout session. You will need it to match webhooks, issue refunds and answer support questions.
- Track status explicitly. Use clear values such as
pending,paid,failed,refundedandpartially_refunded. - Add an idempotency key. This is a unique value you create for each payment attempt. It lets you and the gateway recognise a repeated request, which the section on double charges explains.
A migration for this table might look like this:
Schema::create('payments', function (Blueprint $table) {
$table->id();
$table->foreignId('order_id')->constrained();
$table->string('gateway');
$table->string('gateway_reference')->nullable()->unique();
$table->unsignedBigInteger('amount');
$table->char('currency', 3);
$table->string('status')->default('pending');
$table->unsignedBigInteger('refunded_amount')->default(0);
$table->string('idempotency_key')->unique();
$table->timestamps();
});
Keep gateway keys in your .env file and read them through config/services.php. Never commit them to your code repository.
Our guide on how payment gateways work explains the authorisation, capture and settlement steps these records track.
Redirect and hosted payment flows
Most gateways offer two broad ways to collect payment details:
- Hosted payment page. Your site sends the customer to a page run by the gateway, then the customer returns to your site. Card details never pass through your server.
- Embedded form. The gateway provides a script or component that collects card details inside your page, while the sensitive data still goes straight to the gateway.
Both reduce your security burden compared with collecting card numbers yourself. The PCI Data Security Standard sets the rules for handling card data, and your checkout type affects how much of it applies to you. Hosted pages are usually the simplest place to start.
A typical hosted flow works like this:
- Create a
pendingpayment record. - Ask the gateway to create a checkout session for that amount.
- Save the gateway's reference and redirect the customer.
- When the customer returns, show a "we are confirming your payment" page.
- Mark the payment as paid only when the webhook confirms it.
Here are steps 2 and 3 using Laravel's HTTP client:
$payment = $order->payments()->create([
'gateway' => 'example',
'amount' => $order->total_minor,
'currency' => $order->currency,
'idempotency_key' => (string) Str::uuid(),
]);
$response = Http::withToken(config('services.gateway.secret'))
->withHeaders(['Idempotency-Key' => $payment->idempotency_key])
->timeout(15)
->post(config('services.gateway.url').'/checkout-sessions', [
'amount' => $payment->amount,
'currency' => $payment->currency,
'reference' => (string) $payment->id,
'return_url' => route('checkout.return', $payment),
])
->throw();
$payment->update(['gateway_reference' => $response->json('id')]);
return redirect()->away($response->json('redirect_url'));
Do not mark the order as paid on the return page. Customers can close the browser before returning, and a return URL can be visited by anyone who has the link. The return page should only show status.
Handling the return page
The return page is where customers feel most uncertain, so it should be clear and calm.
- Load the payment by its own ID, and check it belongs to the signed-in customer or the current session.
- Show the current status. If the webhook has already arrived, show the confirmation. If not, show a "confirming your payment" message.
- Refresh the status for a short time. A small script can check the payment status every few seconds, then show the result without the customer reloading the page.
- Explain what happens if it takes longer. Tell the customer they will receive an email once the payment is confirmed, and make sure that email is sent by the webhook handler, not the return page.
- Offer a way back to try again if the gateway reports a failed or cancelled payment, reusing the same order.
Our guide to PCI compliance for small online stores explains how the checkout type changes your card security duties.
Verifying and handling webhooks
A webhook is a request the gateway sends to your server when something happens, such as a payment succeeding, failing or being refunded. It arrives even if the customer never comes back to your site, which is why it should decide the final status.
- Create a dedicated route. For example,
POST /webhooks/gateway. - Exclude it from request forgery protection. The gateway cannot send your site's form token, so Laravel's documentation explains how to exclude such routes.
- Verify the signature. Most gateways sign each webhook with a secret. Check it before trusting anything in the request. The method differs between gateways, so follow your gateway's instructions.
- Respond quickly. Return a success response once the event is safely recorded, and do heavier work in a queued job.
- Process each event once. Gateways may send the same event more than once. Your handler must be safe to run repeatedly.
A simplified job that handles a "payment succeeded" event could look like this:
DB::transaction(function () use ($event) {
$payment = Payment::where('gateway_reference', $event['payment_id'])
->lockForUpdate()
->firstOrFail();
if ($payment->status === 'paid') {
return;
}
$payment->update(['status' => 'paid']);
$payment->order->markAsPaid();
});
The row lock prevents two copies of the same event from updating the payment at the same moment, and the status check makes a repeated event harmless. We cover webhook signatures in more detail, in a travel setting, in our guide to taking payments on a travel booking website.
Our guide on payment webhooks and pending orders explains why webhooks fail and how to monitor for stuck orders.
Preventing double charges
Double charges usually come from repeated requests: a double click, a browser retry after a slow response, or your own code retrying after a timeout.
- Disable the pay button after the first click and show that the payment is processing.
- Reuse the pending payment. If the customer returns to checkout for the same order, reuse the existing pending payment instead of creating a new one.
- Send an idempotency key. Many gateways accept an idempotency key with each request and return the original result if the same key is sent again. Check whether yours does.
- Do not retry payment requests blindly. If a request to create or capture a payment times out, check its status with the gateway before trying again.
- Use database constraints. The unique index on
gateway_referencestops two records from pointing to the same gateway payment.
Checking for stuck and mismatched payments
Even with careful code, webhooks can be delayed or missed, for example during an outage on either side. A small scheduled check catches these cases.
- Find old pending payments. A scheduled command can look for payments that have stayed
pendinglonger than expected. - Ask the gateway for their status. Most gateways let you retrieve a payment by its reference. Update your record from the answer, using the same safe logic as the webhook handler.
- Compare totals regularly. Match the payments and refunds in the gateway's reports against your own records, and flag any difference for review.
- Alert a person when something does not match. A short daily list of mismatches is far easier to handle than a surprise at the end of the month.
Refunds and partial refunds
Refunds should be issued from your application, recorded against the payment and confirmed by the gateway.
- Validate the amount. A refund should never exceed the amount paid minus what has already been refunded.
- Record the request first. Create a refund record with a
pendingstatus and its own idempotency key before calling the gateway. - Call the gateway's refund endpoint. Send the gateway reference and the amount in minor units.
- Confirm by webhook. Update the refund and payment status when the gateway confirms the refund, then set the payment to
refundedorpartially_refunded. - Keep an audit trail. Record who requested each refund and why. Finance teams and customer support will need it.
If your admin panel lets staff issue refunds, limit this to the right roles and ask for confirmation before sending the request.
Testing with sandbox accounts
Gateways provide sandbox accounts: test environments where you can take fake payments with test cards. Use them throughout development, and add automated tests that do not depend on the network.
- Fake outgoing requests. Laravel can fake HTTP responses, so tests can check your code without calling the gateway.
- Test webhooks with real payloads. Save example webhook bodies from the sandbox and replay them in tests, with a valid signature.
- Test the unhappy paths. Declined cards, abandoned checkouts, repeated webhooks and partial refunds matter more than the happy path.
- Test in the sandbox end to end before switching to live keys.
A Pest test for the webhook might look like this, assuming a Payment model with a factory:
test('a payment succeeded webhook marks the payment as paid', function () {
$payment = Payment::factory()->create(['gateway_reference' => 'pay_123']);
$body = json_encode(['type' => 'payment.succeeded', 'payment_id' => 'pay_123']);
$signature = hash_hmac('sha256', $body, config('services.gateway.webhook_secret'));
$this->call('POST', '/webhooks/gateway', server: [
'CONTENT_TYPE' => 'application/json',
'HTTP_X_SIGNATURE' => $signature,
], content: $body)->assertNoContent();
expect($payment->fresh()->status)->toBe('paid');
});
When you go live, switch keys through configuration only, make a small real payment and refund it, and watch the first live payments and webhooks closely.
Summary
- Give payments their own table, store amounts in minor units and keep the gateway reference.
- Start with a hosted payment page and never mark an order paid on the return page.
- Verify webhook signatures and make webhook handling safe to run more than once.
- Prevent double charges with idempotency keys, reused pending payments and database constraints.
- Record refunds before sending them, and test declined, repeated and partial cases.
Payments are one of the few parts of an application where small mistakes cost real money, so they deserve careful structure from the start. If you're adding payments to a Laravel application, you can tell us about it here.