← Back to blog

Inversion of Control in Laravel: building a multi-provider payment orchestrator with dynamic container bindings

2026-07-14

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:

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:

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.