Fintech

Payment Gateway Integration Patterns for Laravel Applications

Proven patterns for integrating payment gateways in Laravel, from abstraction layers to error handling and testing strategies.

Beyond the Quick Start Guide

Every payment gateway provides a quick start guide. Copy some code, make an API call, and money moves. But production payment integrations require far more than a working API call. They need resilience, testability, auditability, and the ability to swap providers without rewriting your application.

This article covers the patterns that separate a prototype from a production-grade payment integration in Laravel.

The Gateway Abstraction Layer

Never scatter payment gateway API calls throughout your application. Create an abstraction that decouples your business logic from any specific PSP.

interface PaymentGateway
{
    public function createPayment(PaymentRequest $request): PaymentResult;
    public function getPayment(string $paymentId): PaymentResult;
    public function refundPayment(string $paymentId, Money $amount): RefundResult;
    public function cancelPayment(string $paymentId): PaymentResult;
}

Each PSP gets its own implementation:

class MollieGateway implements PaymentGateway
{
    public function createPayment(PaymentRequest $request): PaymentResult
    {
        $molliePayment = $this->client->payments->create([
            'amount' => [
                'currency' => $request->amount->getCurrency(),
                'value' => $request->amount->getFormatted(),
            ],
            'description' => $request->description,
            'redirectUrl' => $request->redirectUrl,
            'webhookUrl' => $request->webhookUrl,
            'metadata' => ['order_id' => $request->orderId],
        ]);

        return PaymentResult::fromMollie($molliePayment);
    }
}

Bind the interface in your service container:

$this->app->bind(PaymentGateway::class, MollieGateway::class);

This pattern lets you swap Mollie for Stripe, Adyen, or any other provider by changing one binding. Your controllers, jobs, and business logic never reference a specific PSP.

Value Objects for Money

Never represent money as a float. Currency arithmetic with floating point numbers produces rounding errors that accumulate into real discrepancies. Use a dedicated Money value object:

final class Money
{
    public function __construct(
        private int $cents,
        private string $currency,
    ) {}

    public static function EUR(int $cents): self
    {
        return new self($cents, 'EUR');
    }

    public function getFormatted(): string
    {
        return number_format($this->cents / 100, 2, '.', '');
    }

    public function add(Money $other): self
    {
        $this->assertSameCurrency($other);
        return new self($this->cents + $other->cents, $this->currency);
    }
}

Store amounts in cents (or the smallest currency unit) as integers in your database. Convert to decimal format only at the presentation layer and when communicating with APIs.

Payment State Machine

Payments move through a defined set of states. Model this explicitly rather than relying on ad-hoc status strings:

Created -> Pending -> Paid -> (Refunded | Partially Refunded)
Created -> Pending -> Failed
Created -> Pending -> Expired
Created -> Pending -> Canceled

Use a state machine package or build a simple one with an enum and transition rules:

enum PaymentStatus: string
{
    case Created = 'created';
    case Pending = 'pending';
    case Paid = 'paid';
    case Failed = 'failed';
    case Expired = 'expired';
    case Canceled = 'canceled';
    case Refunded = 'refunded';
}

Enforce valid transitions:

public function transitionTo(PaymentStatus $new): void
{
    $allowed = match($this->status) {
        PaymentStatus::Created => [PaymentStatus::Pending, PaymentStatus::Canceled],
        PaymentStatus::Pending => [PaymentStatus::Paid, PaymentStatus::Failed,
                                    PaymentStatus::Expired, PaymentStatus::Canceled],
        PaymentStatus::Paid => [PaymentStatus::Refunded],
        default => [],
    };

    if (! in_array($new, $allowed)) {
        throw new InvalidPaymentTransition($this->status, $new);
    }

    $this->status = $new;
    $this->save();

    event(new PaymentStatusChanged($this, $new));
}

Webhook Processing

Payment webhooks are the backbone of asynchronous payment flows. Get them right:

Verify signatures. Every legitimate PSP signs its webhook payloads. Always verify the signature before processing. Never skip this in production.

Process idempotently. Webhooks may be delivered multiple times. Use the payment ID as an idempotency key and check the current state before applying transitions.

Respond fast, process later. Return a 200 response immediately, then dispatch a queued job for the actual processing:

public function handleWebhook(Request $request): Response
{
    // Verify signature first
    $this->verifySignature($request);

    // Dispatch async processing
    ProcessPaymentWebhook::dispatch($request->input('id'));

    return response('', 200);
}

Log everything. Store the raw webhook payload before processing. When something goes wrong (and it will), you need the original data for debugging.

Testing Payment Integrations

Unit Tests with Fakes

Create a fake gateway implementation for unit tests:

class FakePaymentGateway implements PaymentGateway
{
    private array $payments = [];

    public function createPayment(PaymentRequest $request): PaymentResult
    {
        $id = 'fake_' . Str::random(10);
        $this->payments[$id] = $request;
        return new PaymentResult($id, PaymentStatus::Pending);
    }
}

Integration Tests with Sandbox

Run integration tests against the PSP's sandbox environment in CI. These catch API changes, serialization issues, and authentication problems that unit tests miss.

Webhook Testing

Simulate webhook deliveries in your test suite:

public function test_paid_webhook_fulfills_order(): void
{
    $order = Order::factory()->create(['status' => 'pending']);
    $payment = Payment::factory()->create([
        'order_id' => $order->id,
        'status' => PaymentStatus::Pending,
    ]);

    $this->post('/webhooks/payments', ['id' => $payment->external_id]);

    $this->assertEquals('paid', $order->fresh()->status);
}

Error Handling and Retry Logic

Payment API calls fail. Networks time out. Rate limits are hit. Handle these gracefully:

  • Retry with exponential backoff for transient errors (5xx, timeouts)
  • Do not retry client errors (4xx) except for rate limiting (429)
  • Circuit breaker pattern for sustained failures: after N consecutive failures, stop calling the API and alert your team

Store failed payment attempts with full error details. Payment debugging months later requires complete records.

A well-structured payment integration is boring in production. That is the goal. Abstract your gateway, model your states explicitly, process webhooks idempotently, and test every path. The investment in architecture pays off the first time you need to handle an edge case at 2 AM.

Let's talk about your fintech needs

Whether you're modernizing your infrastructure, navigating compliance, or building new software - we can help.

Book a 30-min Call