SOLID en Laravel (2/5): Principio Open/Closed con Stripe
16 Dec 2025
8 min read
Introducción
Bienvenido al segundo artículo de nuestra serie SOLID en Laravel. En este artículo exploraremos el Principio Open/Closed (OCP), que establece:
“Las clases deben estar abiertas para extensión, pero cerradas para modificación”
Esto significa que debes poder agregar nueva funcionalidad sin cambiar el código existente.
El Problema: Código Cerrado para Extensión
Imagina que inicialmente solo aceptabas pagos con Stripe, pero ahora necesitas soportar PayPal, y luego Mercado Pago. Así es como muchos desarrolladores lo implementan:
<?php
namespace App\Services;
use Stripe\Stripe;
use Stripe\PaymentIntent;
use PayPal\Rest\ApiContext;
use PayPal\Api\Payment as PayPalPayment;
use MercadoPago\SDK as MercadoPagoSDK;
class PaymentService
{
public function processPayment(float $amount, string $provider, array $data)
{
if ($provider === 'stripe') {
return $this->processStripePayment($amount, $data);
}
elseif ($provider === 'paypal') {
return $this->processPayPalPayment($amount, $data);
}
elseif ($provider === 'mercadopago') {
return $this->processMercadoPagoPayment($amount, $data);
}
throw new \Exception('Provider not supported');
}
private function processStripePayment(float $amount, array $data)
{
Stripe::setApiKey(config('services.stripe.secret'));
$paymentIntent = PaymentIntent::create([
'amount' => $amount * 100,
'currency' => 'usd',
'payment_method' => $data['payment_method'],
'confirmation_method' => 'manual',
'confirm' => true,
]);
return [
'success' => $paymentIntent->status === 'succeeded',
'transaction_id' => $paymentIntent->id,
'provider' => 'stripe',
];
}
private function processPayPalPayment(float $amount, array $data)
{
$apiContext = new ApiContext(
new \PayPal\Auth\OAuthTokenCredential(
config('services.paypal.client_id'),
config('services.paypal.secret')
)
);
// Lógica de PayPal...
$payment = new PayPalPayment();
// ... código de configuración ...
return [
'success' => true,
'transaction_id' => $payment->getId(),
'provider' => 'paypal',
];
}
private function processMercadoPagoPayment(float $amount, array $data)
{
MercadoPagoSDK::setAccessToken(config('services.mercadopago.token'));
// Lógica de Mercado Pago...
return [
'success' => true,
'transaction_id' => 'mp_xxx',
'provider' => 'mercadopago',
];
}
public function refund(string $transactionId, string $provider)
{
if ($provider === 'stripe') {
return $this->refundStripe($transactionId);
}
elseif ($provider === 'paypal') {
return $this->refundPayPal($transactionId);
}
elseif ($provider === 'mercadopago') {
return $this->refundMercadoPago($transactionId);
}
throw new \Exception('Provider not supported');
}
// Más métodos con if/else para cada provider...
}
¿Por qué esto viola el OCP?
Cada vez que quieres agregar un nuevo proveedor de pagos:
- ❌ Modificas la clase
PaymentService - ❌ Agregas más if/else (aumenta complejidad ciclomática)
- ❌ Riesgo de bugs: Un cambio puede romper proveedores existentes
- ❌ Difícil de testear: Necesitas mockear todos los proveedores
- ❌ Violación del OCP: La clase no está cerrada para modificación
La Solución: Aplicando OCP
Vamos a usar interfaces y polimorfismo para poder agregar nuevos proveedores sin modificar código existente:
1. Definir la Interfaz
<?php
namespace App\Contracts;
interface PaymentGatewayInterface
{
public function charge(float $amount, array $paymentData): PaymentResult;
public function refund(string $transactionId, ?float $amount = null): RefundResult;
public function createCustomer(array $customerData): string;
public function getTransactionStatus(string $transactionId): string;
}
2. Objetos de Valor para Resultados
<?php
namespace App\ValueObjects;
class PaymentResult
{
public function __construct(
public readonly bool $success,
public readonly string $transactionId,
public readonly string $status,
public readonly ?string $errorMessage = null,
public readonly array $metadata = []
) {}
public static function success(string $transactionId, string $status, array $metadata = []): self
{
return new self(
success: true,
transactionId: $transactionId,
status: $status,
metadata: $metadata
);
}
public static function failed(string $errorMessage): self
{
return new self(
success: false,
transactionId: '',
status: 'failed',
errorMessage: $errorMessage
);
}
}
3. Implementación para Stripe
<?php
namespace App\Services\PaymentGateways;
use App\Contracts\PaymentGatewayInterface;
use App\ValueObjects\PaymentResult;
use App\ValueObjects\RefundResult;
use Stripe\Stripe;
use Stripe\PaymentIntent;
use Stripe\Customer;
use Stripe\Refund;
class StripeGateway implements PaymentGatewayInterface
{
public function __construct()
{
Stripe::setApiKey(config('services.stripe.secret'));
}
public function charge(float $amount, array $paymentData): PaymentResult
{
try {
$paymentIntent = PaymentIntent::create([
'amount' => $amount * 100,
'currency' => $paymentData['currency'] ?? 'usd',
'payment_method' => $paymentData['payment_method'],
'confirmation_method' => 'manual',
'confirm' => true,
]);
return PaymentResult::success(
transactionId: $paymentIntent->id,
status: $paymentIntent->status,
metadata: [
'client_secret' => $paymentIntent->client_secret,
]
);
} catch (\Exception $e) {
return PaymentResult::failed($e->getMessage());
}
}
public function refund(string $transactionId, ?float $amount = null): RefundResult
{
try {
$refundData = ['payment_intent' => $transactionId];
if ($amount) {
$refundData['amount'] = $amount * 100;
}
$refund = Refund::create($refundData);
return RefundResult::success(
refundId: $refund->id,
amount: $refund->amount / 100
);
} catch (\Exception $e) {
return RefundResult::failed($e->getMessage());
}
}
public function createCustomer(array $customerData): string
{
$customer = Customer::create([
'email' => $customerData['email'],
'name' => $customerData['name'],
]);
return $customer->id;
}
public function getTransactionStatus(string $transactionId): string
{
$paymentIntent = PaymentIntent::retrieve($transactionId);
return $paymentIntent->status;
}
}
4. Implementación para PayPal
<?php
namespace App\Services\PaymentGateways;
use App\Contracts\PaymentGatewayInterface;
use App\ValueObjects\PaymentResult;
use App\ValueObjects\RefundResult;
class PayPalGateway implements PaymentGatewayInterface
{
private $apiContext;
public function __construct()
{
$this->apiContext = new \PayPal\Rest\ApiContext(
new \PayPal\Auth\OAuthTokenCredential(
config('services.paypal.client_id'),
config('services.paypal.secret')
)
);
}
public function charge(float $amount, array $paymentData): PaymentResult
{
try {
// Lógica específica de PayPal
$payment = new \PayPal\Api\Payment();
// ... configuración ...
$payment->create($this->apiContext);
return PaymentResult::success(
transactionId: $payment->getId(),
status: $payment->getState(),
);
} catch (\Exception $e) {
return PaymentResult::failed($e->getMessage());
}
}
public function refund(string $transactionId, ?float $amount = null): RefundResult
{
// Implementación de reembolso PayPal
}
public function createCustomer(array $customerData): string
{
// PayPal no requiere crear clientes de la misma forma
return $customerData['email'];
}
public function getTransactionStatus(string $transactionId): string
{
// Implementación de consulta de estado
}
}
5. Factory para Seleccionar Gateway
<?php
namespace App\Services;
use App\Contracts\PaymentGatewayInterface;
use App\Services\PaymentGateways\StripeGateway;
use App\Services\PaymentGateways\PayPalGateway;
use App\Services\PaymentGateways\MercadoPagoGateway;
class PaymentGatewayFactory
{
public function make(string $provider): PaymentGatewayInterface
{
return match($provider) {
'stripe' => app(StripeGateway::class),
'paypal' => app(PayPalGateway::class),
'mercadopago' => app(MercadoPagoGateway::class),
default => throw new \InvalidArgumentException("Gateway {$provider} not supported")
};
}
}
6. Servicio de Pagos Simplificado
<?php
namespace App\Services;
use App\Contracts\PaymentGatewayInterface;
use App\ValueObjects\PaymentResult;
class PaymentService
{
public function __construct(
private PaymentGatewayFactory $gatewayFactory
) {}
public function processPayment(
float $amount,
string $provider,
array $paymentData
): PaymentResult {
$gateway = $this->gatewayFactory->make($provider);
return $gateway->charge($amount, $paymentData);
}
public function refund(
string $transactionId,
string $provider,
?float $amount = null
): RefundResult {
$gateway = $this->gatewayFactory->make($provider);
return $gateway->refund($transactionId, $amount);
}
}
7. Uso en el Controlador
<?php
namespace App\Http\Controllers;
use App\Services\PaymentService;
use Illuminate\Http\Request;
class PaymentController extends Controller
{
public function __construct(
private PaymentService $paymentService
) {}
public function charge(Request $request)
{
$result = $this->paymentService->processPayment(
amount: $request->amount,
provider: $request->provider, // 'stripe', 'paypal', etc.
paymentData: $request->payment_data
);
if ($result->success) {
return response()->json([
'success' => true,
'transaction_id' => $result->transactionId,
]);
}
return response()->json([
'success' => false,
'error' => $result->errorMessage,
], 400);
}
}
Agregando un Nuevo Gateway (Sin Modificar Código Existente)
Ahora, para agregar Mercado Pago, solo creas una nueva clase:
<?php
namespace App\Services\PaymentGateways;
use App\Contracts\PaymentGatewayInterface;
use App\ValueObjects\PaymentResult;
class MercadoPagoGateway implements PaymentGatewayInterface
{
public function __construct()
{
\MercadoPago\SDK::setAccessToken(config('services.mercadopago.token'));
}
public function charge(float $amount, array $paymentData): PaymentResult
{
try {
$payment = new \MercadoPago\Payment();
$payment->transaction_amount = $amount;
$payment->token = $paymentData['token'];
// ... resto de configuración
$payment->save();
return PaymentResult::success(
transactionId: $payment->id,
status: $payment->status,
);
} catch (\Exception $e) {
return PaymentResult::failed($e->getMessage());
}
}
// ... resto de métodos
}
Y registrarlo en el Factory:
// Solo modificas esta línea en PaymentGatewayFactory
'mercadopago' => app(MercadoPagoGateway::class),
Comparación
Antes (Violando OCP)
- ❌ Modificabas
PaymentServicepor cada nuevo gateway - ❌ Cadenas de if/else creciendo infinitamente
- ❌ Riesgo de romper gateways existentes
- ❌ 200+ líneas en una clase
Después (Aplicando OCP)
- ✅
PaymentServicenunca cambia - ✅ Nuevos gateways = nueva clase (extensión)
- ✅ Gateways existentes no se tocan
- ✅ Cada gateway ~50 líneas, aislado y testeable
Beneficios
1. Agregar Funcionalidad Sin Riesgo
// Antes: Miedo de romper algo
// Después: Nueva clase, cero riesgo
php artisan make:gateway CryptoGateway
2. Testing Simplificado
// Mockea solo la interfaz
public function test_payment_processes_successfully()
{
$mockGateway = Mockery::mock(PaymentGatewayInterface::class);
$mockGateway->shouldReceive('charge')
->andReturn(PaymentResult::success('txn_123', 'succeeded'));
$service = new PaymentService(new PaymentGatewayFactory());
// ...
}
3. Configuración Dinámica
// En config/payments.php
return [
'default_gateway' => env('PAYMENT_GATEWAY', 'stripe'),
'gateways' => [
'stripe' => StripeGateway::class,
'paypal' => PayPalGateway::class,
'mercadopago' => MercadoPagoGateway::class,
],
];
Casos de Uso Reales
A/B Testing de Gateways
class PaymentGatewaySelector
{
public function selectGateway(User $user): string
{
// Testing: 50% Stripe, 50% PayPal
return $user->id % 2 === 0 ? 'stripe' : 'paypal';
}
}
Failover Automático
public function processWithFailover(float $amount, array $data): PaymentResult
{
$providers = ['stripe', 'paypal', 'mercadopago'];
foreach ($providers as $provider) {
$result = $this->processPayment($amount, $provider, $data);
if ($result->success) {
return $result;
}
}
throw new AllGatewaysFailedException();
}
Conclusión
El Principio Open/Closed te permite:
- ✅ Extender funcionalidad sin modificar código existente
- ✅ Reducir riesgo de bugs en código probado
- ✅ Facilitar el testing con interfaces
- ✅ Escalar tu aplicación de forma sostenible
Regla de oro: Si te encuentras modificando una clase cada vez que agregas una feature, probablemente estás violando OCP.
En el próximo artículo exploraremos el Principio de Sustitución de Liskov, donde veremos cómo asegurar que las implementaciones sean intercambiables.
Artículo anterior: SOLID en Laravel (1/5): Principio de Responsabilidad Única
Próximo artículo: SOLID en Laravel (3/5): Principio de Sustitución de Liskov
☕ ¿Te ha sido útil este artículo?
Apóyame con un café mientras sigo creando contenido técnico
☕ Invítame un café