Inversion of Control in Laravel: building a multi-provider payment orchestrator with dynamic container bindings
Inversion of Control in Laravel: Building a Multi-Provider Payment Orchestrator with Dynamic Container Bindings
Payment processing is rarely a single-vendor affair. Most applications need flexibility—whether to support regional preferences, negotiate better rates, or simply avoid vendor lock-in. The challenge isn't integrating multiple providers; it's doing so without creating a maintenance nightmare of scattered if-else blocks and duplicated logic.
This article demonstrates how to leverage Laravel's Inversion of Control (IoC) container to build a clean, extensible payment orchestrator. We'll implement the strategy pattern with dynamic container bindings, using Stripe and MercadoPago as concrete providers.
Why Inversion of Control Matters for Payment Systems
Traditional approaches tightly couple payment logic to specific providers. When you instantiate Stripe or MercadoPago clients directly in controllers, you create dependencies that resist change. Want to swap providers? You'll refactor across multiple files. Need to A/B test gateways? Prepare for conditional spaghetti.
Inversion of Control flips this relationship. Instead of your code pulling dependencies, the framework pushes them—configured externally, resolved automatically, and substituted seamlessly. Laravel's service container excels here, acting as a sophisticated factory that resolves objects and their recursive dependencies.
For payment orchestration, IoC enables three critical capabilities:
- Provider abstraction: Business logic operates against interfaces, not implementations
- Runtime selection: Bindings change based on configuration, user location, or business rules
- Seamless testing: Mock providers inject without code modification
Defining the Contract: The Payment Provider Interface
Every strategy pattern begins with an interface that captures the essential operations. For payments, we need charge creation, refund capability, and webhook handling:
<?php
namespace App\Contracts;
use App\Payment\PaymentRequest;
use App\Payment\PaymentResult;
interface PaymentProvider
{
public function charge(PaymentRequest $request): PaymentResult;
public function refund(string $transactionId, float $amount): PaymentResult;
public function validateWebhookPayload(array $payload, array $headers): bool;
public function parseWebhookPayload(array $payload): WebhookEvent;
}
The interface remains intentionally narrow. Resist the urge to expose provider-specific concepts like Stripe's PaymentIntent states or MercadoPago's preference objects. These leak through return types and parameters, undermining abstraction.
Our value objects carry data without behavior:
<?php
namespace App\Payment;
class PaymentRequest
{
public function __construct(
public readonly float $amount,
public readonly string $currency,
public readonly string $description,
public readonly array $metadata = [],
public readonly ?string $customerEmail = null,
public readonly ?string $token = null,
) {}
}
class PaymentResult
{
public function __construct(
public readonly bool $success,
public readonly string $transactionId,
public readonly ?string $status = null,
public readonly array $rawResponse = [],
public readonly ?string $errorMessage = null,
) {}
}
Concrete Implementations: Stripe and MercadoPago
Each provider maps the universal interface to its native SDK. Here's Stripe:
<?php
namespace App\Payment\Providers;
use App\Contracts\PaymentProvider;
use App\Payment\PaymentRequest;
use App\Payment\PaymentResult;
use Stripe\StripeClient;
class StripeProvider implements PaymentProvider
{
private StripeClient $client;
public function __construct(
private string $secretKey,
private string $webhookSecret,
) {
$this->client = new StripeClient($secretKey);
}
public function charge(PaymentRequest $request): PaymentResult
{
try {
$intent = $this->client->paymentIntents->create([
'amount' => $this->toCents($request->amount),
'currency' => strtolower($request->currency),
'description' => $request->description,
'metadata' => $request->metadata,
'receipt_email' => $request->customerEmail,
'automatic_payment_methods' => ['enabled' => true],
]);
return new PaymentResult(
success: in_array($intent->status, ['succeeded', 'requires_confirmation']),
transactionId: $intent->id,
status: $intent->status,
rawResponse: $intent->toArray(),
);
} catch (\Stripe\Exception\ApiErrorException $e) {
return new PaymentResult(
success: false,
transactionId: 'stripe_' . uniqid(),
errorMessage: $e->getMessage(),
rawResponse: ['code' => $e->getStripeCode()],
);
}
}
public function refund(string $transactionId, float $amount): PaymentResult
{
$refund = $this->client->refunds->create([
'payment_intent' => $transactionId,
'amount' => $this->toCents($amount),
]);
return new PaymentResult(
success: $refund->status === 'succeeded',
transactionId: $refund->id,
status: $refund->status,
);
}
public function validateWebhookPayload(array $payload, array $headers): bool
{
$signature = $headers['stripe-signature'] ?? '';
try {
\Stripe\Webhook::constructEvent(
json_encode($payload),
$signature,
$this->webhookSecret
);
return true;
} catch (\Exception $e) {
return false;
}
}
public function parseWebhookPayload(array $payload): WebhookEvent
{
return new WebhookEvent(
type: str_replace('.', '_', $payload['type']),
providerTransactionId: $payload['data']['object']['id'],
payload: $payload,
);
}
private function toCents(float $amount): int
{
return (int) round($amount * 100);
}
}
MercadoPago follows the same pattern with its SDK nuances:
<?php
namespace App\Payment\Providers;
use App\Contracts\PaymentProvider;
use App\Payment\PaymentRequest;
use App\Payment\PaymentResult;
use MercadoPago\Client\Payment\PaymentClient;
use MercadoPago\MercadoPagoConfig;
class MercadoPagoProvider implements PaymentProvider
{
private PaymentClient $client;
public function __construct(
private string $accessToken,
private string $webhookSecret,
) {
MercadoPagoConfig::setAccessToken($accessToken);
$this->client = new PaymentClient();
}
public function charge(PaymentRequest $request): PaymentResult
{
try {
$response = $this->client->create([
'transaction_amount' => $request->amount,
'currency_id' => strtoupper($request->currency),
'description' => $request->description,
'payer' => ['email' => $request->customerEmail],
'token' => $request->token,
'metadata' => $request->metadata,
]);
return new PaymentResult(
success: in_array($response->status, ['approved', 'in_process']),
transactionId: (string) $response->id,
status: $response->status,
rawResponse: json_decode(json_encode($response), true),
);
} catch (\Exception $e) {
return new PaymentResult(
success: false,
transactionId: 'mp_' . uniqid(),
errorMessage: $e->getMessage(),
);
}
}
public function refund(string $transactionId, float $amount): PaymentResult
{
// MercadoPago refund implementation...
}
public function validateWebhookPayload(array $payload, array $headers): bool
{
$signature = $headers['x-signature'] ?? '';
$id = $payload['data']['id'] ?? '';
$template = "id:{$id};request-id:{$headers['x-request-id'] ?? ''};ts:{$headers['x-timestamp'] ?? ''};";
$calculated = hash_hmac('sha256', $template, $this->webhookSecret);
return hash_equals($calculated, $signature);
}
public function parseWebhookPayload(array $payload): WebhookEvent
{
return new WebhookEvent(
type: $payload['type'] ?? 'unknown',
providerTransactionId: (string) ($payload['data']['id'] ?? ''),
payload: $payload,
);
}
}
Dynamic Container Bindings: The Orchestration Core
Here's where Laravel's container shines. We don't hardcode which provider to instantiate. Instead, we bind resolvers that execute at runtime, considering configuration, user context, and business rules.
First, a dedicated service provider for payment bindings:
<?php
namespace App\Providers;
use App\Contracts\PaymentProvider;
use App\Payment\Providers\MercadoPagoProvider;
use App\Payment\Providers\StripeProvider;
use Illuminate\Support\ServiceProvider;
class PaymentServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->bindDefaultProvider();
$this->bindNamedProviders();
$this->bindContextualResolver();
}
private function bindDefaultProvider(): void
{
$this->app->singleton(PaymentProvider::class, function ($app) {
$driver = config('payments.default');
return $app->make("payment.{$driver}");
});
}
private function bindNamedProviders(): void
{
$this->app->singleton('payment.stripe', function ($app) {
return new StripeProvider(
secretKey: config('services.stripe.secret'),
webhookSecret: config('services.stripe.webhook_secret'),
);
});
$this->app->singleton('payment.mercadopago', function ($app) {
return new MercadoPagoProvider(
accessToken: config('services.mercadopago.access_token'),
webhookSecret: config('services.mercadopago.webhook_secret'),
);
});
}
private function bindContextualResolver(): void
{
$this->app->extend(PaymentProvider::class, function ($provider, $app) {
if ($app->bound('payment.contextual_override')) {
$override = $app->make('payment.contextual_override');
return $app->make("payment.{$override}");
}
return $provider;
});
}
}
This constructs a resolution hierarchy: contextual override wins, then the configured default, each delegating to named implementations with proper credentials.
Contextual Resolution: Regional and Business Logic
Static bindings suffice for simple cases. Real applications need dynamic selection. Consider: MercadoPago dominates Latin America; Stripe excels elsewhere. We inject context through middleware or service calls:
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class ResolvePaymentProvider
{
public function handle(Request $request, Closure $next)
{
$country = $request->input('country', $request->header('X-Country', 'US'));
$isMercadoPagoRegion = in_array($country, ['AR', 'BR', 'CL', 'CO', 'MX', 'PE', 'UY']);
app()->instance('payment.contextual_override',
$isMercadoPagoRegion ? 'mercadopago' : 'stripe'
);
return $next($request);
}
}
For A/B tests or gradual rollouts, inject a decision service:
<?php
namespace App\Payment;
class ProviderResolver
{
public function __construct(
private UserSegmentationService $segmentation,
) {}
public function resolveFor(int $userId): string
{
if ($this->segmentation->isInExperiment($userId, 'stripe-instant-payouts')) {
return 'stripe';
}
$region = $this->segmentation->getUserRegion($userId);
return $region === 'latam' ? 'mercadopago' : 'stripe';
}
}
Update the contextual binding to consult this resolver:
$this->app->beforeResolving(PaymentProvider::class, function ($app) {
if (auth()->check()) {
$resolver = $app->make(ProviderResolver::class);
$provider = $resolver->resolveFor(auth()->id());
$app->instance('payment.contextual_override', $provider);
}
});
The Orchestrator: Clean Consumption
Controllers and services remain blissfully ignorant of which provider they're using. They type-hint the interface, and Laravel resolves the correct implementation:
<?php
namespace App\Http\Controllers\Api;
use App\Contracts\PaymentProvider;
use App\Payment\PaymentRequest;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class PaymentController
{
public function __construct(
private PaymentProvider $provider,
) {}
public function store(Request $request): JsonResponse
{
$paymentRequest = new PaymentRequest(
amount: $request->input('amount'),
currency: $request->input('currency', 'USD'),
description: $request->input('description'),
customerEmail: $request->input('email'),
token: $request->input('payment_token'),
metadata: ['order_id' => $request->input('order_id')],
);
$result = $this->provider->charge($paymentRequest);
if (!$result->success) {
return response()->json([
'error' => $result->errorMessage,
'code' => 'PAYMENT_FAILED',
], 422);
}
return response()->json([
'transaction_id' => $result->transactionId,
'status' => $result->status,
]);
}
public function refund(Request $request, string $transactionId): JsonResponse
{
$result = $this->provider->refund(
$transactionId,
$request->input('amount')
);
return response()->json($result);
}
}
Multi-Provider Operations: Beyond Single Resolution
Sometimes you need multiple providers simultaneously—for migrations, fallbacks, or reconciliation. The container supports this through tagged bindings:
// In service provider
$this->app->tag([
'payment.stripe',
'payment.mercadopago',
], 'payment.providers');
// In command or job
$providers = $app->tagged('payment.providers');
foreach ($providers as $provider) {
$provider->reconcileTransactions($startDate, $endDate);
}
Testing with Confidence
IoC enables surgical test doubles. No refactoring required:
<?php
namespace Tests\Feature;
use App\Contracts\PaymentProvider;
use App\Payment\PaymentResult;
use Tests\TestCase;
class PaymentControllerTest extends TestCase
{
public function test_successful_charge(): void
{
$mockProvider = $this->createMock(PaymentProvider::class);
$mockProvider->method('charge')->willReturn(
new PaymentResult(success: true, transactionId: 'txn_test_123')
);
$this->app->instance(PaymentProvider::class, $mockProvider);
$response = $this->postJson('/api/payments', [
'amount' => 99.99,
'currency' => 'USD',
'email' => 'customer@example.com',
]);
$response->assertOk()
->assertJsonPath('transaction_id', 'txn_test_123');
}
}
Conclusion
Laravel's IoC container transforms payment integration from a tangle of provider-specific code into a composable, testable system. The strategy pattern, expressed through dynamic bindings, lets you adapt to business requirements—regional preferences, A/B tests, failover logic—without rewriting core payment flows.
Key practices to adopt:
- Interface-driven design: Contracts prevent provider leakage
- Named bindings: Multiple implementations coexist cleanly
- Contextual resolution: Runtime decisions inject transparently
- Value objects: Immutable data carriers reduce side effects
- Container tags: Bulk operations iterate registered providers
Start with strict abstraction, add dynamic resolution as requirements demand, and let Laravel's container handle the wiring. Your payment system will gain the flexibility that business reality requires.