SOLID en Laravel (3/5): Principio de Sustitución de Liskov con Stripe
16 Dec 2025
9 min read
Introducción
Bienvenido al tercer artículo de SOLID en Laravel. Hoy exploraremos el Principio de Sustitución de Liskov (LSP), que establece:
“Los objetos de una clase derivada deben poder sustituir objetos de la clase base sin alterar el correcto funcionamiento del programa”
En términos prácticos: Si tu código espera una interfaz, cualquier implementación debe comportarse de forma predecible.
El Problema: Implementaciones Inconsistentes
Siguiendo con nuestro ejemplo de gateways de pago, veamos cómo se viola comúnmente el LSP:
<?php
namespace App\Contracts;
interface PaymentGatewayInterface
{
/**
* Procesa un pago
* @return PaymentResult
*/
public function charge(float $amount, array $data): PaymentResult;
}
Implementación 1: Stripe (Comportamiento Esperado)
<?php
namespace App\Services\PaymentGateways;
class StripeGateway implements PaymentGatewayInterface
{
public function charge(float $amount, array $data): PaymentResult
{
if ($amount <= 0) {
throw new \InvalidArgumentException('Amount must be positive');
}
// Procesa el pago
$paymentIntent = PaymentIntent::create([
'amount' => $amount * 100,
'currency' => 'usd',
'payment_method' => $data['payment_method'],
]);
return new PaymentResult(
success: $paymentIntent->status === 'succeeded',
transactionId: $paymentIntent->id,
);
}
}
Implementación 2: PayPal (Viola LSP)
<?php
namespace App\Services\PaymentGateways;
class PayPalGateway implements PaymentGatewayInterface
{
public function charge(float $amount, array $data): PaymentResult
{
// VIOLACIÓN: Cambia el comportamiento esperado
// 1. No valida montos negativos
if ($amount < 50) {
// Lanza excepción por monto mínimo (nuevo comportamiento)
throw new \Exception('PayPal requires minimum $50');
}
// 2. ❌ Requiere datos diferentes en el array
if (!isset($data['return_url']) || !isset($data['cancel_url'])) {
throw new \Exception('PayPal requires return_url and cancel_url');
}
// 3. ❌ Retorna null en lugar de PaymentResult cuando hay error
try {
$payment = $this->createPayPalPayment($amount, $data);
} catch (\Exception $e) {
return null; // Violación del contrato
}
// 4. ❌ El transactionId puede ser null
return new PaymentResult(
success: true,
transactionId: $payment->getId(), // Puede ser null
);
}
}
Implementación 3: CryptoGateway (Viola LSP Completamente)
<?php
namespace App\Services\PaymentGateways;
class CryptoGateway implements PaymentGatewayInterface
{
// VIOLACIÓN CRÍTICA: Cambia completamente el comportamiento
public function charge(float $amount, array $data): PaymentResult
{
// No procesa el pago inmediatamente
// En su lugar, crea una "factura" que el usuario paga después
$invoice = $this->createCryptoInvoice($amount, $data);
// Retorna "success" aunque NO se haya cobrado
return new PaymentResult(
success: true, // ¡MENTIRA! No se ha cobrado aún
transactionId: $invoice->id,
);
}
}
¿Por qué esto viola LSP?
Cuando tu código usa estas implementaciones, no son intercambiables:
class SubscriptionService
{
public function subscribe(User $user, PaymentGatewayInterface $gateway)
{
// Este código ASUME comportamiento consistente
$result = $gateway->charge(29.99, [
'payment_method' => $user->payment_method,
]);
if ($result->success) {
// Con PayPal: Falla (requiere return_url)
// Con Crypto: Usuario NO está suscrito (pago pendiente)
// Con Stripe: Funciona correctamente
$user->activatePremium();
return $result->transactionId;
// Con PayPal: Puede ser null
}
}
}
Consecuencias:
- ❌ Necesitas
if ($gateway instanceof PayPalGateway)en todos lados - ❌ Bugs difíciles de detectar (dependen del gateway usado)
- ❌ Testing poco confiable
- ❌ No puedes intercambiar implementaciones libremente
La Solución: Cumplir el Contrato
1. Definir un Contrato Sólido
<?php
namespace App\Contracts;
use App\ValueObjects\PaymentResult;
use App\Exceptions\PaymentException;
interface PaymentGatewayInterface
{
/**
* Procesa un cargo inmediato.
*
* Precondiciones:
* - $amount debe ser > 0
* - $data debe contener las claves requeridas por getRequiredFields()
*
* Postcondiciones:
* - SIEMPRE retorna PaymentResult (nunca null)
* - Si success = true, el pago FUE procesado exitosamente
* - Si success = false, el pago NO fue procesado
* - transactionId nunca es null cuando success = true
*
* @throws PaymentException En caso de error de configuración o validación
*/
public function charge(float $amount, array $data): PaymentResult;
/**
* Retorna los campos requeridos en el array $data
*
* @return array Lista de claves requeridas
*/
public function getRequiredFields(): array;
/**
* Retorna el monto mínimo permitido por este gateway
*
* @return float Monto mínimo en USD
*/
public function getMinimumAmount(): float;
/**
* Indica si este gateway procesa pagos de forma síncrona
*
* @return bool true = pago inmediato, false = pago asíncrono
*/
public function isSynchronous(): bool;
}
2. Implementación Correcta de Stripe
<?php
namespace App\Services\PaymentGateways;
use App\Contracts\PaymentGatewayInterface;
use App\ValueObjects\PaymentResult;
use App\Exceptions\PaymentException;
class StripeGateway implements PaymentGatewayInterface
{
public function charge(float $amount, array $data): PaymentResult
{
// Validar precondiciones
$this->validateAmount($amount);
$this->validateRequiredFields($data);
try {
$paymentIntent = PaymentIntent::create([
'amount' => $amount * 100,
'currency' => 'usd',
'payment_method' => $data['payment_method'],
'confirm' => true,
]);
// Garantizar postcondiciones
return PaymentResult::success(
transactionId: $paymentIntent->id, // Nunca null
status: $paymentIntent->status,
);
} catch (\Stripe\Exception\CardException $e) {
// Retorna PaymentResult, no null
return PaymentResult::failed($e->getMessage());
} catch (\Exception $e) {
throw new PaymentException(
"Stripe error: {$e->getMessage()}",
previous: $e
);
}
}
public function getRequiredFields(): array
{
return ['payment_method'];
}
public function getMinimumAmount(): float
{
return 0.50; // Mínimo de Stripe
}
public function isSynchronous(): bool
{
return true; // Stripe procesa inmediatamente
}
private function validateAmount(float $amount): void
{
if ($amount <= 0) {
throw new PaymentException('Amount must be greater than zero');
}
if ($amount < $this->getMinimumAmount()) {
throw new PaymentException(
"Amount must be at least \${$this->getMinimumAmount()}"
);
}
}
private function validateRequiredFields(array $data): void
{
foreach ($this->getRequiredFields() as $field) {
if (!isset($data[$field])) {
throw new PaymentException("Missing required field: {$field}");
}
}
}
}
3. Implementación Correcta de PayPal
<?php
namespace App\Services\PaymentGateways;
use App\Contracts\PaymentGatewayInterface;
use App\ValueObjects\PaymentResult;
use App\Exceptions\PaymentException;
class PayPalGateway implements PaymentGatewayInterface
{
public function charge(float $amount, array $data): PaymentResult
{
// Mismas validaciones que Stripe
$this->validateAmount($amount);
$this->validateRequiredFields($data);
try {
$payment = new \PayPal\Api\Payment();
$payment->setIntent('sale')
->setPayer($this->createPayer($data))
->setTransactions([$this->createTransaction($amount)])
->setRedirectUrls($this->createRedirectUrls($data));
$payment->create($this->apiContext);
// Garantiza transactionId nunca null
$transactionId = $payment->getId() ??
throw new PaymentException('PayPal did not return transaction ID');
return PaymentResult::success(
transactionId: $transactionId,
status: $payment->getState(),
metadata: [
'approval_url' => $this->getApprovalUrl($payment),
]
);
} catch (\PayPal\Exception\PayPalConnectionException $e) {
return PaymentResult::failed($e->getMessage());
}
}
public function getRequiredFields(): array
{
// PayPal documenta sus campos requeridos
return ['payment_method', 'return_url', 'cancel_url'];
}
public function getMinimumAmount(): float
{
return 1.00; // PayPal mínimo real
}
public function isSynchronous(): bool
{
return true; // El cargo se crea inmediatamente
}
// ... resto de métodos
}
4. Adaptador para Gateways Asíncronos
Para gateways como Crypto que NO procesan inmediatamente, usamos un adaptador:
<?php
namespace App\Services\PaymentGateways;
use App\Contracts\PaymentGatewayInterface;
/**
* Adaptador para gateways asíncronos
* Convierte operaciones asíncronas en síncronas mediante polling
*/
class AsyncGatewayAdapter implements PaymentGatewayInterface
{
public function __construct(
private AsynchronousPaymentGateway $asyncGateway,
private int $maxWaitSeconds = 300
) {}
public function charge(float $amount, array $data): PaymentResult
{
// Crear invoice
$invoice = $this->asyncGateway->createInvoice($amount, $data);
// Esperar confirmación o timeout
$startTime = time();
while (time() - $startTime < $this->maxWaitSeconds) {
$status = $this->asyncGateway->checkInvoiceStatus($invoice->id);
if ($status === 'paid') {
return PaymentResult::success(
transactionId: $invoice->id,
status: 'completed'
);
}
if ($status === 'expired') {
return PaymentResult::failed('Invoice expired');
}
sleep(5); // Poll cada 5 segundos
}
// Timeout
return PaymentResult::failed('Payment timeout');
}
public function isSynchronous(): bool
{
return false; // Documenta su naturaleza asíncrona
}
// ... resto de métodos
}
5. Uso con Validación de Precondiciones
<?php
namespace App\Services;
use App\Contracts\PaymentGatewayInterface;
use App\Exceptions\PaymentException;
class PaymentProcessor
{
public function processPayment(
PaymentGatewayInterface $gateway,
float $amount,
array $data
): PaymentResult {
// Validar que se cumplen las precondiciones
if ($amount < $gateway->getMinimumAmount()) {
throw new PaymentException(
"Amount \${$amount} is below minimum \${$gateway->getMinimumAmount()}"
);
}
$requiredFields = $gateway->getRequiredFields();
$missingFields = array_diff($requiredFields, array_keys($data));
if (!empty($missingFields)) {
throw new PaymentException(
'Missing required fields: ' . implode(', ', $missingFields)
);
}
// Ahora SABEMOS que cualquier gateway funcionará igual
$result = $gateway->charge($amount, $data);
// Podemos confiar en las postcondiciones
if ($result->success) {
// transactionId NUNCA será null aquí
$this->logTransaction($result->transactionId, $amount);
}
return $result;
}
public function canProcessSynchronously(PaymentGatewayInterface $gateway): bool
{
return $gateway->isSynchronous();
}
}
Comparación
Antes (Violando LSP)
// Código lleno de verificaciones de tipo
public function subscribe(User $user, PaymentGatewayInterface $gateway)
{
if ($gateway instanceof PayPalGateway) {
$data['return_url'] = route('payment.return');
$data['cancel_url'] = route('payment.cancel');
}
if ($gateway instanceof CryptoGateway) {
// No activar premium inmediatamente
$result = $gateway->charge($amount, $data);
$this->createPendingSubscription($user, $result);
return;
}
$result = $gateway->charge($amount, $data);
if ($result === null) {
// Solo pasa con PayPal mal implementado
throw new \Exception('Payment failed');
}
if ($result->success && $result->transactionId !== null) {
$user->activatePremium();
}
}
Después (Cumpliendo LSP)
// Código limpio, confía en el contrato
public function subscribe(User $user, PaymentGatewayInterface $gateway)
{
// Preparar datos según el gateway
$data = $this->preparePaymentData($user, $gateway);
// Procesar - CUALQUIER gateway funcionará igual
$result = $gateway->charge($amount, $data);
// Confiar en las postcondiciones
if ($result->success) {
// transactionId SIEMPRE existe
$user->activatePremium($result->transactionId);
}
return $result;
}
private function preparePaymentData(User $user, PaymentGatewayInterface $gateway): array
{
$data = ['payment_method' => $user->payment_method];
// El gateway DICE qué necesita
foreach ($gateway->getRequiredFields() as $field) {
if ($field === 'return_url') {
$data[$field] = route('payment.return');
}
if ($field === 'cancel_url') {
$data[$field] = route('payment.cancel');
}
}
return $data;
}
Testing con LSP
<?php
namespace Tests\Unit\PaymentGateways;
use Tests\TestCase;
/**
* Test de Sustitución de Liskov
* TODOS los gateways deben pasar TODOS estos tests
*/
abstract class PaymentGatewayContractTest extends TestCase
{
abstract protected function createGateway(): PaymentGatewayInterface;
public function test_charge_with_valid_data_returns_payment_result()
{
$gateway = $this->createGateway();
$result = $gateway->charge(100, [
'payment_method' => 'test_pm_123',
]);
$this->assertInstanceOf(PaymentResult::class, $result);
}
public function test_successful_charge_has_transaction_id()
{
$gateway = $this->createGateway();
$result = $gateway->charge(100, $this->getValidPaymentData());
if ($result->success) {
$this->assertNotNull($result->transactionId);
$this->assertNotEmpty($result->transactionId);
}
}
public function test_charge_throws_exception_for_negative_amount()
{
$this->expectException(PaymentException::class);
$gateway = $this->createGateway();
$gateway->charge(-50, $this->getValidPaymentData());
}
public function test_charge_throws_exception_for_missing_required_fields()
{
$this->expectException(PaymentException::class);
$gateway = $this->createGateway();
$gateway->charge(100, []); // Sin campos requeridos
}
}
// Cada gateway extiende este test
class StripeGatewayTest extends PaymentGatewayContractTest
{
protected function createGateway(): PaymentGatewayInterface
{
return new StripeGateway();
}
}
class PayPalGatewayTest extends PaymentGatewayContractTest
{
protected function createGateway(): PaymentGatewayInterface
{
return new PayPalGateway();
}
}
Reglas para Cumplir LSP
- No fortalecer precondiciones: Las subclases no pueden requerir más que la clase base
- No debilitar postcondiciones: Las subclases deben garantizar al menos lo mismo
- Preservar invariantes: Las reglas de la clase base deben mantenerse
- No lanzar excepciones nuevas: Solo las documentadas en la clase base
Conclusión
El Principio de Sustitución de Liskov garantiza que:
- ✅ Las implementaciones sean verdaderamente intercambiables
- ✅ No necesites
instanceofen tu código - ✅ Los tests sean confiables y reutilizables
- ✅ El polimorfismo funcione correctamente
Regla de oro: Si tu código pregunta “¿qué tipo de implementación es esto?”, estás violando LSP.
En el próximo artículo exploraremos el Principio de Segregación de Interfaces, donde veremos cómo crear interfaces pequeñas y enfocadas.
Artículo anterior: SOLID en Laravel (2/5): Principio Open/Closed Próximo artículo: SOLID en Laravel (4/5): Principio de Segregación de Interfaces