SOLID en Laravel (1/5): Principio de Responsabilidad Única con Stripe
16 Dec 2025
8 min read
Introducción
Bienvenido a la primera parte de nuestra serie sobre Principios SOLID en Laravel. En esta serie de 5 artículos, exploraremos cada principio SOLID usando un caso de uso real: la integración de pagos con Stripe.
En este primer artículo, nos enfocaremos en el Principio de Responsabilidad Única (Single Responsibility Principle - SRP), que establece que:
“Una clase debe tener una única razón para cambiar”
El Problema: Implementación Típica
Veamos cómo muchos desarrolladores implementan inicialmente la creación de suscripciones en Stripe:
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Stripe\Stripe;
use Stripe\Customer;
use Stripe\Subscription;
use App\Models\User;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use App\Mail\SubscriptionCreated;
class SubscriptionController extends Controller
{
public function subscribe(Request $request)
{
// Validación
$validated = $request->validate([
'payment_method' => 'required|string',
'plan_id' => 'required|string',
]);
$user = auth()->user();
try {
// Configurar Stripe
Stripe::setApiKey(config('services.stripe.secret'));
// Crear o recuperar cliente de Stripe
if (!$user->stripe_customer_id) {
$customer = Customer::create([
'email' => $user->email,
'name' => $user->name,
'payment_method' => $validated['payment_method'],
'invoice_settings' => [
'default_payment_method' => $validated['payment_method'],
],
]);
$user->update(['stripe_customer_id' => $customer->id]);
} else {
$customer = Customer::retrieve($user->stripe_customer_id);
}
// Crear suscripción
$subscription = Subscription::create([
'customer' => $customer->id,
'items' => [['price' => $validated['plan_id']]],
'expand' => ['latest_invoice.payment_intent'],
]);
// Guardar en base de datos
$user->subscriptions()->create([
'stripe_subscription_id' => $subscription->id,
'stripe_plan_id' => $validated['plan_id'],
'status' => $subscription->status,
'trial_ends_at' => $subscription->trial_end
? now()->setTimestamp($subscription->trial_end)
: null,
'ends_at' => null,
]);
// Registrar en logs
Log::info('Subscription created', [
'user_id' => $user->id,
'subscription_id' => $subscription->id,
]);
// Enviar email de confirmación
Mail::to($user)->send(new SubscriptionCreated($user, $subscription));
// Aplicar lógica de negocio post-suscripción
if ($subscription->status === 'active') {
$user->update(['premium_until' => now()->addMonth()]);
$user->assignRole('premium');
}
return response()->json([
'success' => true,
'subscription' => $subscription,
]);
} catch (\Exception $e) {
Log::error('Subscription failed', [
'user_id' => $user->id,
'error' => $e->getMessage(),
]);
return response()->json([
'success' => false,
'error' => 'No se pudo crear la suscripción',
], 500);
}
}
}
¿Qué está mal con este código?
Este controlador viola el SRP porque tiene múltiples responsabilidades:
- ✗ Validación de entrada
- ✗ Comunicación con la API de Stripe
- ✗ Gestión de clientes de Stripe
- ✗ Creación de suscripciones
- ✗ Persistencia en base de datos
- ✗ Logging de eventos
- ✗ Envío de emails
- ✗ Lógica de negocio (roles, fechas premium)
- ✗ Gestión de errores
Problemas que esto causa:
- 🔴 Difícil de testear: Necesitas mockear Stripe, Mail, Log, DB en cada test
- 🔴 Imposible de reutilizar: No puedes crear suscripciones desde CLI, Jobs, etc.
- 🔴 Difícil de mantener: Un cambio en Stripe afecta el controlador completo
- 🔴 Violación del SRP: Tiene 9 razones diferentes para cambiar
La Solución: Aplicando SRP
Vamos a refactorizar este código separando las responsabilidades. Pero primero, entendamos por qué movemos cada parte a un lugar específico:
Arquitectura de Capas en Laravel
Services (Servicios): Encapsulan la comunicación con servicios externos (APIs, SDKs). El StripeSubscriptionService se encarga SOLO de hablar con Stripe, sin mezclar lógica de negocio.
Repositories (Repositorios): Manejan la persistencia de datos. El SubscriptionRepository se encarga SOLO de guardar/recuperar datos de la base de datos, siguiendo el patrón Repository.
Actions (Acciones): Orquestan la lógica de negocio. El CreateSubscriptionAction coordina los pasos necesarios para crear una suscripción, combinando servicios y repositorios.
Listeners (Escuchadores): Manejan efectos secundarios. Los listeners reaccionan a eventos y ejecutan tareas complementarias (enviar emails, actualizar roles) sin acoplar la lógica principal.
Form Requests: Encapsulan las reglas de validación. Separan la validación de la lógica del controlador, haciendo el código más limpio y reutilizable.
Controllers (Controladores): Punto de entrada HTTP. Solo delegan a las acciones, manteniendo la lógica de negocio fuera del controlador.
Ahora veamos el código refactorizado:
1. Servicio de Stripe (Comunicación con API Externa)
<?php
namespace App\Services\Stripe;
use Stripe\Stripe;
use Stripe\Customer;
use Stripe\Subscription;
class StripeSubscriptionService
{
public function __construct()
{
Stripe::setApiKey(config('services.stripe.secret'));
}
public function createOrRetrieveCustomer(string $email, string $name, ?string $stripeCustomerId = null): Customer
{
if ($stripeCustomerId) {
return Customer::retrieve($stripeCustomerId);
}
return Customer::create([
'email' => $email,
'name' => $name,
]);
}
public function attachPaymentMethod(string $customerId, string $paymentMethodId): void
{
Customer::update($customerId, [
'invoice_settings' => [
'default_payment_method' => $paymentMethodId,
],
]);
}
public function createSubscription(string $customerId, string $planId): Subscription
{
return Subscription::create([
'customer' => $customerId,
'items' => [['price' => $planId]],
'expand' => ['latest_invoice.payment_intent'],
]);
}
}
2. Repositorio de Suscripciones (Persistencia de Datos)
<?php
namespace App\Repositories;
use App\Models\User;
use App\Models\Subscription as SubscriptionModel;
use Stripe\Subscription as StripeSubscription;
class SubscriptionRepository
{
public function createFromStripe(User $user, StripeSubscription $stripeSubscription, string $planId): SubscriptionModel
{
return $user->subscriptions()->create([
'stripe_subscription_id' => $stripeSubscription->id,
'stripe_plan_id' => $planId,
'status' => $stripeSubscription->status,
'trial_ends_at' => $stripeSubscription->trial_end
? now()->setTimestamp($stripeSubscription->trial_end)
: null,
'ends_at' => null,
]);
}
}
3. Acción de Negocio (Orquestación)
<?php
namespace App\Actions\Subscriptions;
use App\Models\User;
use App\Services\Stripe\StripeSubscriptionService;
use App\Repositories\SubscriptionRepository;
use App\Events\SubscriptionCreated;
use Illuminate\Support\Facades\DB;
class CreateSubscriptionAction
{
public function __construct(
private StripeSubscriptionService $stripeService,
private SubscriptionRepository $subscriptionRepo,
) {}
public function execute(User $user, string $paymentMethodId, string $planId): array
{
return DB::transaction(function () use ($user, $paymentMethodId, $planId) {
// 1. Gestionar cliente de Stripe
$customer = $this->stripeService->createOrRetrieveCustomer(
$user->email,
$user->name,
$user->stripe_customer_id
);
if (!$user->stripe_customer_id) {
$user->update(['stripe_customer_id' => $customer->id]);
}
// 2. Adjuntar método de pago
$this->stripeService->attachPaymentMethod($customer->id, $paymentMethodId);
// 3. Crear suscripción en Stripe
$stripeSubscription = $this->stripeService->createSubscription(
$customer->id,
$planId
);
// 4. Persistir en base de datos
$subscription = $this->subscriptionRepo->createFromStripe(
$user,
$stripeSubscription,
$planId
);
// 5. Disparar evento (observers se encargarán del resto)
event(new SubscriptionCreated($user, $subscription));
return [
'subscription' => $subscription,
'stripe_subscription' => $stripeSubscription,
];
});
}
}
4. Form Request (Validación)
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class SubscribeRequest extends FormRequest
{
public function authorize(): bool
{
return auth()->check();
}
public function rules(): array
{
return [
'payment_method' => 'required|string',
'plan_id' => 'required|string|exists:plans,id',
];
}
public function messages(): array
{
return [
'payment_method.required' => 'El método de pago es requerido',
'plan_id.required' => 'El plan es requerido',
'plan_id.exists' => 'El plan seleccionado no existe',
];
}
}
5. Controlador Simplificado (Punto de Entrada HTTP)
<?php
namespace App\Http\Controllers;
use App\Http\Requests\SubscribeRequest;
use App\Actions\Subscriptions\CreateSubscriptionAction;
use Illuminate\Support\Facades\Log;
class SubscriptionController extends Controller
{
public function __construct(
private CreateSubscriptionAction $createSubscription
) {}
public function subscribe(SubscribeRequest $request)
{
// La validación ya ocurrió en el FormRequest
try {
$result = $this->createSubscription->execute(
auth()->user(),
$request->validated('payment_method'),
$request->validated('plan_id')
);
return response()->json([
'success' => true,
'subscription' => $result['subscription'],
]);
} catch (\Exception $e) {
Log::error('Subscription failed', [
'user_id' => auth()->id(),
'error' => $e->getMessage(),
]);
return response()->json([
'success' => false,
'error' => 'No se pudo crear la suscripción',
], 500);
}
}
}
6. Event Listeners (Efectos Secundarios)
¿Por qué separar en listeners? Los efectos secundarios como enviar emails o actualizar roles no son parte del flujo principal. Si fallan, no deben romper la suscripción. Los eventos permiten desacoplar estas responsabilidades.
<?php
namespace App\Listeners;
use App\Events\SubscriptionCreated;
use Illuminate\Support\Facades\Mail;
use App\Mail\SubscriptionCreatedMail;
class SendSubscriptionEmail
{
public function handle(SubscriptionCreated $event): void
{
Mail::to($event->user)->send(
new SubscriptionCreatedMail($event->user, $event->subscription)
);
}
}
<?php
namespace App\Listeners;
use App\Events\SubscriptionCreated;
class ActivatePremiumFeatures
{
public function handle(SubscriptionCreated $event): void
{
if ($event->subscription->status === 'active') {
$event->user->update([
'premium_until' => now()->addMonth()
]);
$event->user->assignRole('premium');
}
}
}
Comparación
Antes (Violando SRP)
- ❌ 1 clase con 9 responsabilidades
- ❌ 150+ líneas en el controlador
- ❌ Validación mezclada con lógica
- ❌ Imposible de testear unitariamente
- ❌ Lógica de negocio mezclada con infraestructura
Después (Aplicando SRP)
- ✅ 6 clases, cada una con 1 responsabilidad clara
- ✅ Controlador de ~20 líneas
- ✅ Validación separada en FormRequest
- ✅ Cada clase es fácil de testear
- ✅ Lógica de negocio separada de infraestructura
- ✅ Reutilizable desde cualquier parte (CLI, Jobs, etc.)
Beneficios Obtenidos
1. Testeabilidad
// Ahora puedes testear cada pieza independientemente
public function test_stripe_service_creates_customer()
{
$service = new StripeSubscriptionService();
$customer = $service->createOrRetrieveCustomer('test@test.com', 'Test User');
$this->assertNotNull($customer->id);
}
2. Reutilización
// Usar desde un comando Artisan
class UpgradeUserCommand extends Command
{
public function handle(CreateSubscriptionAction $action)
{
$user = User::find($this->argument('user_id'));
$action->execute($user, $paymentMethod, $planId);
}
}
3. Mantenibilidad
- Cambios en Stripe → Solo modificas
StripeSubscriptionService - Cambios en DB → Solo modificas
SubscriptionRepository - Cambios en emails → Solo modificas el Listener
Conclusión
El Principio de Responsabilidad Única no significa que una clase deba hacer solo una cosa, sino que debe tener una única razón para cambiar. Al aplicarlo:
- Tus clases son más pequeñas y enfocadas
- Tu código es más fácil de testear
- Los cambios son más seguros y localizados
- Tu aplicación es más mantenible a largo plazo
En el próximo artículo exploraremos el Principio Open/Closed, donde veremos cómo extender el comportamiento de pagos sin modificar el código existente.
Recursos
Próximo artículo: SOLID en Laravel (2/5): Principio Open/Closed
☕ ¿Te ha sido útil este artículo?
Apóyame con un café mientras sigo creando contenido técnico
☕ Invítame un café