Payment Integration Is Not Just an API Call
Every developer's first payment integration looks the same: call the API, check the response, save the transaction. Ship it.
Then the real world happens — network timeouts, duplicate charges, webhook delivery failures, currency rounding errors, partial refunds, and that one customer who clicked "Pay" seventeen times.
This guide covers the patterns that separate a fragile payment integration from a production-ready one.
The Strategy Pattern: Supporting Multiple Gateways
Most applications eventually need to support multiple payment providers. The strategy pattern makes this clean:
interface PaymentGateway
{
public function charge(Money $amount, PaymentMethod $method): PaymentResult;
public function refund(string $transactionId, ?Money $amount = null): RefundResult;
public function supportsMethod(string $method): bool;
}
class StripeGateway implements PaymentGateway
{
public function __construct(private readonly StripeClient $client) {}
public function charge(Money $amount, PaymentMethod $method): PaymentResult
{
try {
$intent = $this->client->paymentIntents->create([
'amount' => $amount->inCents(),
'currency' => strtolower($amount->currency),
'payment_method' => $method->token,
'confirm' => true,
'idempotency_key' => $method->idempotencyKey,
]);
return PaymentResult::success(
transactionId: $intent->id,
amount: $amount,
);
} catch (CardException $e) {
return PaymentResult::failed($e->getMessage(), $e->getDeclineCode());
}
}
// ...
}
class PayPalGateway implements PaymentGateway
{
// Same interface, different implementation
}
// Resolution
class PaymentGatewayFactory
{
public function make(string $provider): PaymentGateway
{
return match($provider) {
'stripe' => app(StripeGateway::class),
'paypal' => app(PayPalGateway::class),
default => throw new UnsupportedGatewayException($provider),
};
}
}
Idempotency: Preventing Double Charges
This is the single most important pattern in payment processing. If a user double-clicks, if your server retries after a timeout, if a webhook fires twice — the customer should only be charged once.
class ProcessPayment
{
public function execute(PaymentRequest $request): PaymentResult
{
// Generate a deterministic idempotency key
$idempotencyKey = $this->generateKey($request);
// Check if we've already processed this payment
$existing = Payment::where('idempotency_key', $idempotencyKey)->first();
if ($existing) {
return PaymentResult::fromExisting($existing);
}
// Create a pending record BEFORE calling the gateway
$payment = Payment::create([
'idempotency_key' => $idempotencyKey,
'amount' => $request->amount,
'status' => PaymentStatus::Pending,
'gateway' => $request->gateway,
]);
try {
$result = $this->gateway->charge($request->amount, $request->method);
$payment->update([
'status' => $result->success ? PaymentStatus::Completed : PaymentStatus::Failed,
'transaction_id' => $result->transactionId,
'gateway_response' => $result->rawResponse,
]);
return $result;
} catch (Throwable $e) {
$payment->update(['status' => PaymentStatus::Error, 'error' => $e->getMessage()]);
throw $e;
}
}
private function generateKey(PaymentRequest $request): string
{
return hash('sha256', implode('|', [
$request->orderId,
$request->amount->inCents(),
$request->amount->currency,
$request->userId,
]));
}
}
Webhook Handling: The Backbone of Payment State
Never trust client-side payment confirmation alone. Webhooks are the source of truth.
class StripeWebhookController extends Controller
{
public function handle(Request $request): Response
{
// 1. Verify the webhook signature
try {
$event = Webhook::constructEvent(
$request->getContent(),
$request->header('Stripe-Signature'),
config('services.stripe.webhook_secret')
);
} catch (SignatureVerificationException $e) {
return response('Invalid signature', 400);
}
// 2. Check for duplicate delivery
if (WebhookLog::where('event_id', $event->id)->exists()) {
return response('Already processed', 200);
}
// 3. Log before processing
WebhookLog::create([
'event_id' => $event->id,
'type' => $event->type,
'payload' => $event->toArray(),
]);
// 4. Dispatch to handler
match($event->type) {
'payment_intent.succeeded' => $this->handlePaymentSuccess($event),
'payment_intent.payment_failed' => $this->handlePaymentFailure($event),
'charge.refunded' => $this->handleRefund($event),
default => null, // Ignore unhandled events
};
// 5. Always return 200 to prevent retries
return response('OK', 200);
}
}
Error Recovery Patterns
Payments can fail in interesting ways. Here's how to handle the most common scenarios:
- Gateway timeout: Don't retry blindly. Check the payment status first, then retry only if the original charge didn't go through
- Partial failure: If the charge succeeded but your database write failed, the webhook will reconcile the state
- Declined cards: Return user-friendly messages mapped from decline codes
- Currency mismatch: Validate currency at the input layer, never at the gateway layer
Testing Payment Integrations
class ProcessPaymentTest extends TestCase
{
public function test_idempotent_payment_does_not_double_charge(): void
{
$gateway = Mockery::mock(PaymentGateway::class);
$gateway->shouldReceive('charge')
->once() // Critical: should only be called ONCE
->andReturn(PaymentResult::success('txn_123', new Money(1000)));
$processor = new ProcessPayment($gateway);
$request = new PaymentRequest(orderId: 1, amount: new Money(1000));
$result1 = $processor->execute($request);
$result2 = $processor->execute($request);
$this->assertTrue($result1->success);
$this->assertTrue($result2->success);
$this->assertEquals(1, Payment::count()); // Only one record
}
}
Checklist Before Going Live
- Webhook signature verification is enabled
- Idempotency keys are generated for all charges
- All monetary amounts use integer cents, not floats
- Webhook endpoint returns 200 before processing (use queued jobs)
- Failed webhook deliveries are monitored and alerting is configured
- PCI compliance requirements are understood (use hosted payment forms)
- Refund flows are tested end-to-end