Building a Scalable Payment Integration System in Laravel


Introduction
Handling payments is a critical part of many web applications. Whether you're building an e-commerce platform, a SaaS application, or any service that requires payments, you need a robust and flexible payment system that can adapt to changing requirements.
In this blog post, I'll show you how to build a scalable payment integration system in Laravel that makes it easy to add new payment methods in the future using the adapter pattern. This approach separates your business logic from the specifics of each payment gateway, making your codebase more maintainable and extensible.
The Problem with Direct Integrations
When integrating payment gateways directly into your application code, you often end up with tightly coupled, hard-to-maintain code:
// Controller with direct payment gateway integration
public function processPayment(Request $request)
{
if ($request->payment_method === 'stripe') {
// Stripe-specific code
$stripe = new \Stripe\StripeClient(config('services.stripe.secret'));
$paymentIntent = $stripe->paymentIntents->create([
'amount' => $request->amount * 100,
'currency' => 'usd',
// More Stripe-specific parameters
]);
// Handle Stripe response
}
elseif ($request->payment_method === 'paypal') {
// PayPal-specific code
// Create PayPal order
// Handle PayPal response
}
// More payment methods...
}
This approach has several problems:
Violation of the Open/Closed Principle: You need to modify existing code to add new payment methods
Code duplication: Common functionality (like logging) gets repeated
Testing difficulties: Harder to write unit tests because of tight coupling
Inconsistent error handling: Each gateway might handle errors differently
Let's solve these problems with a well-structured architecture.
Designing a Scalable Payment Architecture
The key to a scalable payment system is building an abstraction layer that standardizes how your application interacts with different payment gateways. We'll use the adapter pattern to create a uniform interface for all payment gateways.
Here's the architecture we'll implement:
Payment Gateway Interface: Defines the contract all payment gateways must implement
Abstract Payment Gateway: Implements common functionality for all gateways
Gateway Implementations: Concrete classes for Stripe, PayPal, etc.
Payment Service: Factory class to create gateway instances based on configuration
Payment Transaction Model: For storing payment records in the database
Step 1: Create the Payment Gateway Interface
First, let's define the contract that all payment gateways must follow:
<?php
namespace App\Services\Payment\Contracts;
interface PaymentGatewayInterface
{
/**
* Process a payment
*
* @param float $amount Amount to charge
* @param array $paymentData Payment details (card info, etc)
* @param array $metadata Additional data
* @return array Payment response with transaction ID and status
*/
public function charge(float $amount, array $paymentData, array $metadata = []);
/**
* Refund a payment
*
* @param string $transactionId Original transaction ID
* @param float|null $amount Amount to refund (null for full refund)
* @return array Refund response with refund ID and status
*/
public function refund(string $transactionId, ?float $amount = null);
/**
* Get payment status
*
* @param string $transactionId Transaction ID to check
* @return array Payment status response
*/
public function getStatus(string $transactionId);
/**
* Create payment intent/setup for client side processing
*
* @param float $amount Amount to charge
* @param array $metadata Additional data
* @return array Intent/setup data for the client
*/
public function createIntent(float $amount, array $metadata = []);
/**
* Process webhook data from the payment provider
*
* @param array $payload The webhook payload
* @return array Processing result
*/
public function handleWebhook(array $payload);
}
This interface defines all the methods that a payment gateway needs to implement. By standardizing this contract, we ensure that all gateways can be used interchangeably in our application.
Step 2: Create an Abstract Payment Gateway
Next, let's create an abstract class that implements common functionality for all gateways:
<?php
namespace App\Services\Payment;
use App\Services\Payment\Contracts\PaymentGatewayInterface;
use App\Models\PaymentTransaction;
use Illuminate\Support\Facades\Log;
abstract class AbstractPaymentGateway implements PaymentGatewayInterface
{
/**
* Format money amount according to gateway requirements
*/
protected function formatAmount(float $amount): int|float
{
// Default implementation - can be overridden by gateways
return $amount;
}
/**
* Log payment transaction
*/
protected function logTransaction(string $type, array $data): void
{
// Skip logging if disabled
if (!config('payment.logging.enabled', true)) {
return;
}
// Log to configured channel
$channel = config('payment.logging.channel', 'stack');
Log::channel($channel)->info("Payment {$type}: ", $data);
// Create transaction record in database
try {
PaymentTransaction::create([
'type' => $type,
'gateway' => $this->getGatewayName(),
'amount' => $data['amount'] ?? null,
'transaction_id' => $data['transaction_id'] ?? null,
'reference_id' => $data['reference_id'] ?? null,
'status' => $data['status'] ?? null,
'metadata' => json_encode($data),
]);
} catch (\Exception $e) {
Log::error("Failed to save payment transaction: " . $e->getMessage());
}
}
/**
* Handle API errors in a consistent way
*/
protected function handleApiError(\Exception $e): array
{
Log::error("Payment API Error: " . $e->getMessage(), [
'exception' => get_class($e),
'message' => $e->getMessage(),
'code' => $e->getCode(),
'gateway' => $this->getGatewayName(),
]);
return [
'success' => false,
'error' => $e->getMessage(),
'error_code' => $e->getCode(),
];
}
/**
* Get the name of the current gateway
*/
protected function getGatewayName(): string
{
// Extract gateway name from class name
$class = get_class($this);
$parts = explode('\\', $class);
$className = end($parts);
return str_replace('Gateway', '', $className);
}
}
This abstract class provides common functionality like error handling, transaction logging, and amount formatting. Concrete gateway implementations will extend this class and override specific methods as needed.
Step 3: Implement Gateway Classes
Now, let's create concrete implementations for some popular payment gateways. Here's an example for Stripe:
<?php
namespace App\Services\Payment\Gateways;
use App\Services\Payment\AbstractPaymentGateway;
use Stripe\StripeClient;
use Stripe\Exception\ApiErrorException;
use Stripe\Webhook;
use Stripe\Exception\SignatureVerificationException;
class StripeGateway extends AbstractPaymentGateway
{
protected $stripe;
public function __construct()
{
$this->stripe = new StripeClient(config('services.stripe.secret'));
}
protected function formatAmount(float $amount): int
{
// Stripe requires amounts in cents/pennies
return (int) ($amount * 100);
}
public function charge(float $amount, array $paymentData, array $metadata = []): array
{
try {
$formattedAmount = $this->formatAmount($amount);
$paymentIntent = $this->stripe->paymentIntents->create([
'amount' => $formattedAmount,
'currency' => config('services.stripe.currency', 'usd'),
'payment_method' => $paymentData['payment_method_id'] ?? null,
'confirm' => true,
'metadata' => $metadata,
]);
$this->logTransaction('charge', [
'amount' => $amount,
'transaction_id' => $paymentIntent->id,
'reference_id' => $metadata['order_id'] ?? null,
'status' => $paymentIntent->status,
]);
return [
'success' => $paymentIntent->status === 'succeeded',
'transaction_id' => $paymentIntent->id,
'status' => $paymentIntent->status,
'gateway_response' => $paymentIntent,
];
} catch (ApiErrorException $e) {
return $this->handleApiError($e);
}
}
public function refund(string $transactionId, ?float $amount = null): array
{
try {
$refundData = ['payment_intent' => $transactionId];
if ($amount !== null) {
$refundData['amount'] = $this->formatAmount($amount);
}
$refund = $this->stripe->refunds->create($refundData);
$this->logTransaction('refund', [
'transaction_id' => $transactionId,
'refund_id' => $refund->id,
'amount' => $amount,
'status' => $refund->status,
]);
return [
'success' => $refund->status === 'succeeded',
'refund_id' => $refund->id,
'status' => $refund->status,
'gateway_response' => $refund,
];
} catch (ApiErrorException $e) {
return $this->handleApiError($e);
}
}
public function getStatus(string $transactionId): array
{
try {
$paymentIntent = $this->stripe->paymentIntents->retrieve($transactionId);
return [
'success' => true,
'status' => $paymentIntent->status,
'amount' => $paymentIntent->amount / 100, // Convert back from cents
'gateway_response' => $paymentIntent,
];
} catch (ApiErrorException $e) {
return $this->handleApiError($e);
}
}
public function createIntent(float $amount, array $metadata = []): array
{
try {
$formattedAmount = $this->formatAmount($amount);
$paymentIntent = $this->stripe->paymentIntents->create([
'amount' => $formattedAmount,
'currency' => config('services.stripe.currency', 'usd'),
'metadata' => $metadata,
'automatic_payment_methods' => ['enabled' => true],
]);
return [
'success' => true,
'client_secret' => $paymentIntent->client_secret,
'intent_id' => $paymentIntent->id,
'gateway' => 'stripe',
'publishable_key' => config('services.stripe.key'),
];
} catch (ApiErrorException $e) {
return $this->handleApiError($e);
}
}
public function handleWebhook(array $payload): array
{
try {
$webhookSecret = config('payment.settings.stripe.webhook_secret');
// Get the signature from the headers
$signature = request()->header('Stripe-Signature');
// Verify webhook signature
$event = Webhook::constructEvent(
request()->getContent(),
$signature,
$webhookSecret
);
// Handle the event based on its type
switch ($event->type) {
case 'payment_intent.succeeded':
$paymentIntent = $event->data->object;
// Handle successful payment
$this->logTransaction('webhook', [
'type' => $event->type,
'transaction_id' => $paymentIntent->id,
'status' => $paymentIntent->status,
'amount' => $paymentIntent->amount / 100,
]);
break;
case 'payment_intent.payment_failed':
$paymentIntent = $event->data->object;
// Handle failed payment
$this->logTransaction('webhook', [
'type' => $event->type,
'transaction_id' => $paymentIntent->id,
'status' => $paymentIntent->status,
'amount' => $paymentIntent->amount / 100,
'error' => $paymentIntent->last_payment_error,
]);
break;
// More event types...
default:
// Log but don't process other events
$this->logTransaction('webhook', [
'type' => $event->type,
'id' => $event->id,
]);
break;
}
return [
'success' => true,
'message' => 'Webhook processed successfully',
'event_type' => $event->type,
];
} catch (SignatureVerificationException $e) {
// Invalid signature
return [
'success' => false,
'error' => 'Invalid signature',
'message' => $e->getMessage(),
];
} catch (\Exception $e) {
return $this->handleApiError($e);
}
}
}
Similarly, you can implement classes for other payment gateways like PayPal, Razorpay, etc. Each gateway class will handle the specific requirements of that payment provider while maintaining a consistent interface.
Step 4: Create a Payment Service
Now, let's create a service class that acts as a factory for creating gateway instances:
<?php
namespace App\Services\Payment;
use App\Services\Payment\Contracts\PaymentGatewayInterface;
use InvalidArgumentException;
class PaymentService
{
/**
* Get a payment gateway instance
*
* @param string|null $gateway Gateway name (stripe, paypal, etc.)
* @return PaymentGatewayInterface
* @throws InvalidArgumentException If the gateway is not supported
*/
public function gateway(?string $gateway = null): PaymentGatewayInterface
{
// Use default gateway if none specified
$gateway = $gateway ?? config('payment.default_gateway');
// Get available gateways from config
$gateways = config('payment.gateways', []);
if (!isset($gateways[$gateway])) {
throw new InvalidArgumentException("Payment gateway [{$gateway}] is not supported.");
}
$gatewayClass = $gateways[$gateway];
return app($gatewayClass);
}
/**
* List all available payment gateways
*
* @return array Array of available gateway names
*/
public function availableGateways(): array
{
return array_keys(config('payment.gateways', []));
}
/**
* Check if a gateway is available
*
* @param string $gateway Gateway name to check
* @return bool True if gateway is available
*/
public function hasGateway(string $gateway): bool
{
$gateways = config('payment.gateways', []);
return isset($gateways[$gateway]);
}
}
This service class provides a clean interface for working with payment gateways in your application. It uses the Laravel service container to instantiate gateway classes as needed.
Step 5: Create a Payment Facade
To make it even easier to use the payment service, let's create a facade:
<?php
namespace App\Facades;
use Illuminate\Support\Facades\Facade;
/**
* @method static \App\Services\Payment\Contracts\PaymentGatewayInterface gateway(string $gateway = null)
* @method static array availableGateways()
* @method static bool hasGateway(string $gateway)
*
* @see \App\Services\Payment\PaymentService
*/
class Payment extends Facade
{
/**
* Get the registered name of the component.
*
* @return string
*/
protected static function getFacadeAccessor()
{
return 'payment';
}
}
Step 6: Register the Service Provider
Create a service provider to bind the payment service to the container:
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use App\Services\Payment\PaymentService;
class PaymentServiceProvider extends ServiceProvider
{
/**
* Register services.
*
* @return void
*/
public function register()
{
// Register the payment service as a singleton
$this->app->singleton(PaymentService::class, function ($app) {
return new PaymentService();
});
// Register the facade
$this->app->bind('payment', function ($app) {
return $app->make(PaymentService::class);
});
// Register gateway implementations
$gateways = config('payment.gateways', []);
foreach ($gateways as $name => $implementation) {
$this->app->bind($implementation, function ($app) use ($implementation) {
return new $implementation();
});
}
}
/**
* Bootstrap services.
*
* @return void
*/
public function boot()
{
// Publish the config file
$this->publishes([
__DIR__.'/../config/payment.php' => config_path('payment.php'),
], 'payment-config');
}
}
Don't forget to register this service provider in your config/app.php
file.
Step 7: Create the Configuration File
Create a configuration file at config/payment.php
:
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Payment Gateway
|--------------------------------------------------------------------------
|
| This option controls the default payment gateway that will be used for
| payment processing when no gateway is specified.
|
*/
'default_gateway' => env('PAYMENT_GATEWAY', 'stripe'),
/*
|--------------------------------------------------------------------------
| Available Payment Gateways
|--------------------------------------------------------------------------
|
| This array maps gateway identifiers to their implementation classes.
| To add a new payment gateway, simply add it to this array with a
| unique identifier as the key and the gateway class as the value.
|
*/
'gateways' => [
'stripe' => \App\Services\Payment\Gateways\StripeGateway::class,
'paypal' => \App\Services\Payment\Gateways\PayPalGateway::class,
// Add new gateways here as needed
],
/*
|--------------------------------------------------------------------------
| Payment Gateway Configurations
|--------------------------------------------------------------------------
|
| This section contains gateway-specific configuration options.
| Configuration is primarily stored in services.php and .env files,
| but you can add additional gateway-specific settings here.
|
*/
'settings' => [
'stripe' => [
'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'),
'payment_methods' => ['card', 'sepa_debit', 'ideal'],
],
'paypal' => [
'mode' => env('PAYPAL_MODE', 'sandbox'), // sandbox or live
'webhook_id' => env('PAYPAL_WEBHOOK_ID'),
],
],
/*
|--------------------------------------------------------------------------
| Payment Logging
|--------------------------------------------------------------------------
|
| Enable or disable logging of payment transactions for debugging
| and audit purposes.
|
*/
'logging' => [
'enabled' => env('PAYMENT_LOGGING', true),
'channel' => env('PAYMENT_LOG_CHANNEL', 'stack'),
],
];
Step 8: Create the Payment Transaction Model
To store payment transactions in the database, create a model:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class PaymentTransaction extends Model
{
protected $fillable = [
'type',
'gateway',
'amount',
'transaction_id',
'reference_id',
'status',
'metadata',
];
protected $casts = [
'amount' => 'float',
'metadata' => 'array',
];
/**
* Scope a query to only include transactions of a specific gateway.
*/
public function scopeGateway($query, $gateway)
{
return $query->where('gateway', $gateway);
}
/**
* Scope a query to only include transactions of a specific type.
*/
public function scopeOfType($query, $type)
{
return $query->where('type', $type);
}
/**
* Scope a query to only include successful transactions.
*/
public function scopeSuccessful($query)
{
return $query->whereIn('status', ['COMPLETED', 'succeeded', 'captured', 'settled']);
}
/**
* Scope a query to only include failed transactions.
*/
public function scopeFailed($query)
{
return $query->whereIn('status', ['FAILED', 'failed', 'declined', 'error']);
}
}
And don't forget to create a migration:
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class CreatePaymentTransactionsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('payment_transactions', function (Blueprint $table) {
$table->id();
$table->string('type'); // charge, refund, webhook, etc.
$table->string('gateway'); // stripe, paypal, etc.
$table->decimal('amount', 10, 2)->nullable();
$table->string('transaction_id')->nullable(); // Provider's transaction ID
$table->string('reference_id')->nullable(); // Internal reference (order ID, etc.)
$table->string('status')->nullable();
$table->json('metadata')->nullable(); // Additional data
$table->timestamps();
// Indexes
$table->index('transaction_id');
$table->index('reference_id');
$table->index(['gateway', 'type']);
$table->index('status');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('payment_transactions');
}
}
Step 9: Create Controller and Routes
Now, let's create a controller to handle payment requests:
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Facades\Payment;
class PaymentController extends Controller
{
/**
* Display a listing of available payment methods.
*/
public function methods()
{
$gateways = Payment::availableGateways();
return response()->json([
'success' => true,
'gateways' => $gateways,
]);
}
/**
* Create a payment intent/session for client-side processing.
*/
public function createIntent(Request $request)
{
$request->validate([
'amount' => 'required|numeric|min:0.01',
'gateway' => 'sometimes|string',
'order_id' => 'sometimes|string',
]);
$gateway = $request->input('gateway');
$amount = $request->input('amount');
$intent = Payment::gateway($gateway)->createIntent($amount, [
'order_id' => $request->input('order_id'),
'customer_id' => auth()->id(),
]);
return response()->json($intent);
}
/**
* Process a payment.
*/
public function processPayment(Request $request)
{
$request->validate([
'amount' => 'required|numeric|min:0.01',
'gateway' => 'sometimes|string',
'payment_data' => 'required|array',
'order_id' => 'sometimes|string',
]);
$gateway = $request->input('gateway');
$amount = $request->input('amount');
$paymentData = $request->input('payment_data');
$payment = Payment::gateway($gateway)->charge($amount, $paymentData, [
'order_id' => $request->input('order_id'),
'customer_id' => auth()->id(),
]);
if ($payment['success']) {
// Handle successful payment
return response()->json([
'success' => true,
'message' => 'Payment processed successfully',
'transaction_id' => $payment['transaction_id'] ?? null,
'data' => $payment,
]);
} else {
// Handle failed payment
return response()->json([
'success' => false,
'message' => $payment['error'] ?? 'Payment processing failed',
'data' => $payment,
], 422);
}
}
/**
* Handle webhooks from payment providers.
*/
public function webhook(Request $request, string $gateway)
{
// Get the raw payload
$payload = $request->all();
// Process the webhook
$result = Payment::gateway($gateway)->handleWebhook($payload);
if ($result['success']) {
return response()->json(['status' => 'success']);
} else {
return response()->json(['status' => 'error', 'message' => $result['error'] ?? null], 422);
}
}
}
Finally, add routes in your routes/web.php
and routes/api.php
files:
// web.php
Route::prefix('payment')->middleware(['web', 'auth'])->group(function() {
Route::get('methods', [PaymentController::class, 'methods'])->name('payment.methods');
Route::post('create-intent', [PaymentController::class, 'createIntent'])->name('payment.create-intent');
Route::post('process', [PaymentController::class, 'processPayment'])->name('payment.process');
});
// Webhook routes (no CSRF protection)
Route::prefix('payment/webhooks')->middleware('api')->group(function() {
Route::post('{gateway}', [PaymentController::class, 'webhook'])->name('payment.webhook');
});
Using the Payment System
Now that everything is set up, here's how you can use the payment system in your application:
// In a controller or service
use App\Facades\Payment;
// Get available payment methods
$paymentMethods = Payment::availableGateways();
// Create a payment intent for client-side processing
$intent = Payment::gateway('stripe')->createIntent(100.00, [
'order_id' => 'ORD-123',
]);
// Process a payment server-side
$payment = Payment::gateway('stripe')->charge(100.00, [
'payment_method_id' => 'pm_...',
], [
'order_id' => 'ORD-123',
]);
// Check if payment was successful
if ($payment['success']) {
// Payment succeeded
$transactionId = $payment['transaction_id'];
}
Adding a New Gateway
The real beauty of this system is how easy it is to add a new payment gateway. Here's what you need to do:
Create a new gateway class that extends
AbstractPaymentGateway
Implement the required methods from the interface
Register the new gateway in the configuration file
// Step 1: Create the gateway class
namespace App\Services\Payment\Gateways;
use App\Services\Payment\AbstractPaymentGateway;
class MollieGateway extends AbstractPaymentGateway
{
// Implement the required methods
}
// Step 2: Register in config/payment.php
'gateways' => [
'stripe' => \App\Services\Payment\Gateways\StripeGateway::class,
'paypal' => \App\Services\Payment\Gateways\PayPalGateway::class,
'mollie' => \App\Services\Payment\Gateways\MollieGateway::class,
],
That's it! Now you can use the new gateway just like any other:
$payment = Payment::gateway('mollie')->charge(100.00, $paymentData);
Conclusion
In this article, we've built a scalable payment integration system for Laravel applications. This architecture follows SOLID principles, particularly the Open/Closed Principle, allowing you to extend the system with new payment gateways without modifying existing code.
Key benefits of this approach:
Maintainability: Clean, organized code structure
Flexibility: Easy to add new payment methods
Consistency: Standardized API across all gateways
Testability: Easy to write unit tests for each component
Reliability: Centralized error handling and logging
By implementing this pattern, you'll save yourself from headaches as your application grows and payment requirements change. Instead of refactoring spaghetti code every time you need to add a new payment method, you can simply create a new gateway class and register it in the configuration.
Remember, good architecture pays off in the long run. Taking the time to design a proper abstraction layer for your payment system will make your life easier when requirements inevitably change.
Happy coding!
Subscribe to my newsletter
Read articles from Suhail Osman directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
