← Volver al blog

Inversión de control en Laravel: implementando un orquestador de pagos multi-proveedor con bindings dinámicos

2026-07-14

Introducción: el problema real de múltiples proveedores de pago

Cuando una aplicación comienza a procesar pagos, típicamente se integra con un único proveedor. El problema surge cuando el negocio crece: diferentes mercados prefieren métodos distintos, las comisiones varían por país, o necesitás redundancia ante caídas de servicio. En ese momento, tener lógica de pago acoplada a Stripe o MercadoPago se convierte en un dolor de cabeza.

La inversión de control (IoC) en Laravel ofrece una solución elegante. No se trata solo de "usar interfaces", sino de diseñar un sistema donde el framework resuelva automáticamente qué proveedor ejecutar según el contexto operativo. En este artículo construiremos un orquestador de pagos que delega dinámicamente entre Stripe y MercadoPago sin que el código consumidor conozca esa decisión.

Arquitectura del sistema: contratos antes que implementaciones

El principio fundamental es programar contra abstracciones. Definimos primero qué operaciones necesitamos, no cómo se ejecutan.

<?php

namespace App\Contracts\Payments;

interface PaymentProcessor
{
    public function charge(array $payload): TransactionResult;
    
    public function refund(string $transactionId, float $amount): RefundResult;
    
    public function supportsCurrency(string $currency): bool;
    
    public function getProviderName(): string;
}

interface TransactionResult
{
    public function isSuccessful(): bool;
    public function getTransactionId(): ?string;
    public function getRawResponse(): array;
}

interface RefundResult
{
    public function isSuccessful(): bool;
    public function getRefundId(): ?string;
}

Esta interfaz encapsula el qué, no el cómo. Notá los dos métodos clave para la orquestación: supportsCurrency() determina elegibilidad contextual, y getProviderName() permite trazabilidad.

Implementación concreta: adaptador para Stripe

<?php

namespace App\Services\Payments\Providers;

use App\Contracts\Payments\{PaymentProcessor, TransactionResult, RefundResult};
use Stripe\StripeClient;

class StripeAdapter implements PaymentProcessor
{
    private StripeClient $client;
    
    public function __construct(StripeClient $client)
    {
        $this->client = $client;
    }
    
    public function charge(array $payload): TransactionResult
    {
        $intent = $this->client->paymentIntents->create([
            'amount' => $payload['amount_cents'],
            'currency' => strtolower($payload['currency']),
            'payment_method' => $payload['payment_method_id'],
            'confirm' => true,
            'automatic_payment_methods' => ['enabled' => true],
        ]);
        
        return new StripeTransactionResult($intent);
    }
    
    public function refund(string $transactionId, float $amount): RefundResult
    {
        $refund = $this->client->refunds->create([
            'payment_intent' => $transactionId,
            'amount' => (int) ($amount * 100),
        ]);
        
        return new StripeRefundResult($refund);
    }
    
    public function supportsCurrency(string $currency): bool
    {
        $supported = ['USD', 'EUR', 'GBP', 'AUD', 'CAD'];
        return in_array(strtoupper($currency), $supported, true);
    }
    
    public function getProviderName(): string
    {
        return 'stripe';
    }
}

Implementación concreta: adaptador para MercadoPago

<?php

namespace App\Services\Payments\Providers;

use App\Contracts\Payments\{PaymentProcessor, TransactionResult, RefundResult};
use MercadoPago\Client\Payment\PaymentClient;
use MercadoPago\Resources\Payment;

class MercadoPagoAdapter implements PaymentProcessor
{
    private PaymentClient $client;
    
    public function __construct(PaymentClient $client)
    {
        $this->client = $client;
    }
    
    public function charge(array $payload): TransactionResult
    {
        $payment = $this->client->create([
            'transaction_amount' => $payload['amount'],
            'token' => $payload['token'],
            'installments' => $payload['installments'] ?? 1,
            'payment_method_id' => $payload['payment_method_id'],
            'payer' => [
                'email' => $payload['payer_email'],
            ],
        ]);
        
        return new MercadoPagoTransactionResult($payment);
    }
    
    public function refund(string $transactionId, float $amount): RefundResult
    {
        $refund = $this->client->refund($transactionId, [
            'amount' => $amount,
        ]);
        
        return new MercadoPagoRefundResult($refund);
    }
    
    public function supportsCurrency(string $currency): bool
    {
        $supported = ['ARS', 'BRL', 'CLP', 'COP', 'MXN', 'UYU', 'PEN'];
        return in_array(strtoupper($currency), $supported, true);
    }
    
    public function getProviderName(): string
    {
        return 'mercadopago';
    }
}

El orquestador: lógica de selección desacoplada del framework

El orquestador encapsula la regla de negocio que determina qué proveedor utilizar. Es importante que esta lógica sea explícita y testeable, no oculta en la configuración del contenedor.

<?php

namespace App\Services\Payments;

use App\Contracts\Payments\PaymentProcessor;
use InvalidArgumentException;

class PaymentOrchestrator
{
    /**
     * @var array<string, PaymentProcessor>
     */
    private array $processors = [];
    
    public function register(string $name, PaymentProcessor $processor): void
    {
        $this->processors[$name] = $processor;
    }
    
    public function resolveFor(array $context): PaymentProcessor
    {
        $currency = strtoupper($context['currency'] ?? throw new InvalidArgumentException('Currency required'));
        $preferredProvider = $context['preferred_provider'] ?? null;
        
        if ($preferredProvider !== null && isset($this->processors[$preferredProvider])) {
            $candidate = $this->processors[$preferredProvider];
            if ($candidate->supportsCurrency($currency)) {
                return $candidate;
            }
            throw new InvalidArgumentException("Provider {$preferredProvider} does not support {$currency}");
        }
        
        foreach ($this->processors as $processor) {
            if ($processor->supportsCurrency($currency)) {
                return $processor;
            }
        }
        
        throw new InvalidArgumentException("No processor available for currency {$currency}");
    }
    
    public function availableProviders(): array
    {
        return array_keys($this->processors);
    }
}

El método resolveFor() implementa una estrategia de selección con prioridad: primero respeta la preferencia explícita del cliente si es compatible, luego recorre todos los registrados. Esto permite tanto flexibilidad como control.

Bindings dinámicos en el container de Laravel

Aquí es donde Laravel brilla. El contenedor IoC permite registrar resoluciones condicionales que evalúan el contexto de ejecución en tiempo real, no solo al bootear la aplicación.

Provider de servicios: registro contextual

<?php

namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use App\Contracts\Payments\PaymentProcessor;
use App\Services\Payments\PaymentOrchestrator;
use App\Services\Payments\Providers\{StripeAdapter, MercadoPagoAdapter};
use Stripe\StripeClient;
use MercadoPago\Client\Payment\PaymentClient;
use MercadoPago\MercadoPagoConfig;

class PaymentServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->registerStripe();
        $this->registerMercadoPago();
        $this->registerOrchestrator();
        $this->registerContextualBinding();
    }
    
    private function registerStripe(): void
    {
        $this->app->singleton(StripeClient::class, function () {
            return new StripeClient(config('payments.stripe.secret_key'));
        });
        
        $this->app->singleton(StripeAdapter::class, function ($app) {
            return new StripeAdapter($app->make(StripeClient::class));
        });
    }
    
    private function registerMercadoPago(): void
    {
        $this->app->singleton(PaymentClient::class, function () {
            MercadoPagoConfig::setAccessToken(config('payments.mercadopago.access_token'));
            return new PaymentClient();
        });
        
        $this->app->singleton(MercadoPagoAdapter::class, function ($app) {
            return new MercadoPagoAdapter($app->make(PaymentClient::class));
        });
    }
    
    private function registerOrchestrator(): void
    {
        $this->app->singleton(PaymentOrchestrator::class, function ($app) {
            $orchestrator = new PaymentOrchestrator();
            
            $orchestrator->register('stripe', $app->make(StripeAdapter::class));
            $orchestrator->register('mercadopago', $app->make(MercadoPagoAdapter::class));
            
            return $orchestrator;
        });
    }
    
    private function registerContextualBinding(): void
    {
        $this->app->bind(PaymentProcessor::class, function ($app) {
            $request = $app->make('request');
            
            $context = [
                'currency' => $request->input('currency', $request->header('X-Payment-Currency')),
                'preferred_provider' => $request->input('provider', $request->header('X-Preferred-Provider')),
            ];
            
            if ($context['currency'] === null) {
                throw new \RuntimeException('Currency context required for payment resolution');
            }
            
            return $app->make(PaymentOrchestrator::class)
                        ->resolveFor($context);
        });
    }
}

El truco está en la última sección: bind(PaymentProcessor::class, ...) no retorna una instancia fija. Cada vez que alguien solicita un PaymentProcessor, Laravel evalúa la request actual y delega al orquestador. Es inversión de control en su máxima expresión: el framework decide, el código consumidor solo declara su necesidad.

Uso en controladores: total transparencia del proveedor

<?php

namespace App\Http\Controllers\Api;

use App\Contracts\Payments\PaymentProcessor;
use App\Http\Controllers\Controller;
use App\Http\Requests\ProcessPaymentRequest;

class PaymentController extends Controller
{
    public function __construct(
        private PaymentProcessor $processor
    ) {}

    public function store(ProcessPaymentRequest $request)
    {
        $result = $this->processor->charge($request->validated());
        
        return response()->json([
            'success' => $result->isSuccessful(),
            'transaction_id' => $result->getTransactionId(),
            'provider' => $this->processor->getProviderName(),
            'details' => $result->getRawResponse(),
        ]);
    }
}

El controlador ignora completamente cuál proveedor ejecutó el pago. Pero la respuesta incluye provider para trazabilidad operativa. Esto es crítico: la abstracción no debe ocultar información de auditoría.

Resolución explícita para casos especiales

No siempre queremos inyección automática. Para jobs en background o comandos, necesitamos especificar el contexto manualmente:

<?php

namespace App\Jobs;

use App\Contracts\Payments\PaymentProcessor;
use App\Services\Payments\PaymentOrchestrator;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;

class ProcessSubscriptionRenewal implements ShouldQueue
{
    use Queueable;
    
    public function __construct(
        private string $currency,
        private array $paymentData,
        private ?string $preferredProvider = null,
    ) {}

    public function handle(PaymentOrchestrator $orchestrator): void
    {
        $processor = $orchestrator->resolveFor([
            'currency' => $this->currency,
            'preferred_provider' => $this->preferredProvider,
        ]);
        
        // Lógica con retry específico del proveedor
        $result = retry(
            3,
            fn () => $processor->charge($this->paymentData),
            1000,
            fn ($exception) => $this->isRetryable($exception, $processor)
        );
        
        // ...
    }
    
    private function isRetryable(\Throwable $e, PaymentProcessor $processor): bool
    {
        return match ($processor->getProviderName()) {
            'stripe' => $e instanceof \Stripe\Exception\RateLimitException,
            'mercadopago' => $e->getCode() === 429,
            default => false,
        };
    }
}

Testing: el verdadero beneficio de la abstracción

Con el contrato bien definido, los tests no necesitan APIs externas:

<?php

namespace Tests\Unit\Payments;

use App\Contracts\Payments\{PaymentProcessor, TransactionResult};
use App\Services\Payments\PaymentOrchestrator;
use PHPUnit\Framework\TestCase;

class PaymentOrchestratorTest extends TestCase
{
    public function test_resolves_by_currency_preference(): void
    {
        $orchestrator = new PaymentOrchestrator();
        
        $stripe = $this->createMock(PaymentProcessor::class);
        $stripe->method('supportsCurrency')
               ->willReturnCallback(fn ($c) => in_array($c, ['USD', 'EUR']));
        $stripe->method('getProviderName')->willReturn('stripe');
        
        $mp = $this->createMock(PaymentProcessor::class);
        $mp->method('supportsCurrency')
           ->willReturnCallback(fn ($c) => $c === 'ARS');
        $mp->method('getProviderName')->willReturn('mercadopago');
        
        $orchestrator->register('stripe', $stripe);
        $orchestrator->register('mercadopago', $mp);
        
        $this->assertSame($mp, $orchestrator->resolveFor(['currency' => 'ARS']));
        $this->assertSame($stripe, $orchestrator->resolveFor(['currency' => 'USD']));
    }
    
    public function test_respects_explicit_provider_preference(): void
    {
        // ...
    }
    
    public function test_fails_on_unsupported_currency(): void
    {
        $this->expectException(\InvalidArgumentException::class);
        
        $orchestrator = new PaymentOrchestrator();
        $orchestrator->resolveFor(['currency' => 'JPY']);
    }
}

Configuración de fallback y circuit breaker

Para producción, extendemos el orquestador con resiliencia:

    public function resolveWithFallback(array $context): PaymentProcessor
    {
        $primary = $this->resolveFor($context);
        
        if ($this->circuitBreaker->isOpen($primary->getProviderName())) {
            $available = array_filter(
                $this->processors,
                fn ($p) => $p !== $primary 
                       && $p->supportsCurrency($context['currency'])
                       && $this->circuitBreaker->isClosed($p->getProviderName())
            );
            
            return $available[array_key_first($available)] 
                ?? throw new \RuntimeException('No healthy provider available');
        }
        
        return $primary;
    }

Conclusiones

La inversión de control en Laravel no es magia: es diseño deliberado. Al separar la resolución del proveedor de su uso, ganamos:

El pattern strategy implementado vía container bindings dinámicos transforma una decisión de runtime en una abstracción compilable. El código que procesa pagos declara "necesito un procesador"; el framework responde "dado este contexto, este es el apropiado". Esa es la inversión de control: no vos, el sistema.