La mayoría de aplicaciones SaaS comienzan como un monolito Laravel con controladores gigantes, lógica de negocio mezclada con queries de base de datos y tests imposibles de mantener. Funciona al principio. Pero cuando el producto crece, ese código se convierte en una deuda técnica que frena cada nueva feature. La Arquitectura Hexagonal combinada con Domain-Driven Design no es una moda académica — es la diferencia entre un SaaS que escala y uno que hay que reescribir cada dos años.
¿Qué es la Arquitectura Hexagonal?
La Arquitectura Hexagonal, también llamada Ports & Adapters, fue propuesta por Alistair Cockburn con un objetivo claro: aislar completamente la lógica de negocio de cualquier detalle técnico externo — base de datos, HTTP, colas de trabajo, servicios de email o APIs de terceros.
El principio central es que tu dominio no sabe ni le importa si está siendo llamado desde una petición HTTP, un comando Artisan, un job de Queue o un test unitario. Todos estos son adaptadores externos que hablan con tu dominio a través de puertos bien definidos.
La estructura en tres zonas:
text
┌─────────────────────────────────────────┐
│ INFRAESTRUCTURA │
│ (Laravel, MySQL, Stripe, S3, Redis) │
│ │
│ ┌─────────────────────────────┐ │
│ │ APLICACIÓN │ │
│ │ (Use Cases / Commands) │ │
│ │ │ │
│ │ ┌─────────────────┐ │ │
│ │ │ DOMINIO │ │ │
│ │ │ (Puro PHP) │ │ │
│ │ └─────────────────┘ │ │
│ └─────────────────────────────┘ │
└─────────────────────────────────────────┘La regla de dependencia es estricta: las flechas de dependencia siempre apuntan hacia adentro. El dominio no depende de nada. La aplicación depende del dominio. La infraestructura depende de la aplicación.
Domain-Driven Design: Los Bloques Fundamentales
DDD es la filosofía que guía cómo modelar el dominio. No es un framework ni una librería — es una forma de pensar el software alrededor del problema de negocio real.
Los building blocks esenciales de DDD:
Entities — Objetos con identidad única que persiste en el tiempo:
php
// Domain/User/User.php
final class User
{
private UserId $id;
private Email $email;
private UserName $name;
private Plan $plan;
public function __construct(UserId $id, Email $email, UserName $name, Plan $plan)
{
$this->id = $id;
$this->email = $email;
$this->name = $name;
$this->plan = $plan;
}
public function upgradePlan(Plan $newPlan): void
{
if ($this->plan->equals($newPlan)) {
throw new PlanAlreadyActiveException($newPlan);
}
$this->plan = $newPlan;
}
public function id(): UserId { return $this->id; }
public function email(): Email { return $this->email; }
public function plan(): Plan { return $this->plan; }
}Value Objects — Objetos sin identidad, definidos solo por su valor:
php
// Domain/User/Email.php
final class Email
{
private string $value;
public function __construct(string $value)
{
if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
throw new InvalidEmailException($value);
}
$this->value = strtolower(trim($value));
}
public function value(): string { return $this->value; }
public function equals(Email $other): bool
{
return $this->value === $other->value();
}
}Aggregates — Grupo de entidades con una raíz que garantiza consistencia:
php
// Domain/Subscription/Subscription.php — Aggregate Root
final class Subscription
{
private SubscriptionId $id;
private UserId $userId;
private Plan $plan;
private SubscriptionStatus $status;
private \DateTimeImmutable $createdAt;
private array $domainEvents = [];
public function cancel(): void
{
if ($this->status->isCancelled()) {
throw new SubscriptionAlreadyCancelledException();
}
$this->status = SubscriptionStatus::cancelled();
$this->recordEvent(new SubscriptionCancelled($this->id, $this->userId));
}
public function pullDomainEvents(): array
{
$events = $this->domainEvents;
$this->domainEvents = [];
return $events;
}
private function recordEvent(DomainEvent $event): void
{
$this->domainEvents[] = $event;
}
}
Ports y Adapters: La Clave del Aislamiento
Un Port es una interfaz PHP que define el contrato. Un Adapter es la implementación concreta. Tu dominio solo conoce el puerto — nunca el adaptador.
php
// Domain/User/UserRepository.php — PORT (interfaz)
interface UserRepository
{
public function save(User $user): void;
public function findById(UserId $id): ?User;
public function findByEmail(Email $email): ?User;
public function nextIdentity(): UserId;
}
php
// Infrastructure/Persistence/EloquentUserRepository.php — ADAPTER
final class EloquentUserRepository implements UserRepository
{
public function save(User $user): void
{
UserModel::updateOrCreate(
['id' => $user->id()->value()],
[
'email' => $user->email()->value(),
'name' => $user->name()->value(),
'plan' => $user->plan()->value(),
]
);
}
public function findById(UserId $id): ?User
{
$model = UserModel::find($id->value());
return $model ? $this->toDomain($model) : null;
}
public function findByEmail(Email $email): ?User
{
$model = UserModel::where('email', $email->value())->first();
return $model ? $this->toDomain($model) : null;
}
public function nextIdentity(): UserId
{
return UserId::fromString((string) Str::uuid());
}
private function toDomain(UserModel $model): User
{
return new User(
UserId::fromString($model->id),
new Email($model->email),
new UserName($model->name),
Plan::fromString($model->plan)
);
}
}El día que quieras migrar de MySQL a PostgreSQL, MongoDB o incluso una API externa, solo cambias el Adapter. El dominio no toca una sola línea.
Use Cases: La Capa de Aplicación
Los Use Cases (también llamados Application Services o Command Handlers) orquestan el flujo. Reciben un Command, llaman al dominio y despachan los eventos resultantes.
php
// Application/User/RegisterUser/RegisterUserCommand.php
final class RegisterUserCommand
{
public function __construct(
public readonly string $email,
public readonly string $name,
public readonly string $plan
) {}
}
php
// Application/User/RegisterUser/RegisterUserHandler.php
final class RegisterUserHandler
{
public function __construct(
private UserRepository $userRepository,
private EventBus $eventBus,
private PasswordHasher $hasher
) {}
public function handle(RegisterUserCommand $command): void
{
$email = new Email($command->email);
// Regla de negocio: no duplicar emails
if ($this->userRepository->findByEmail($email) !== null) {
throw new UserAlreadyExistsException($email);
}
$user = new User(
$this->userRepository->nextIdentity(),
$email,
new UserName($command->name),
Plan::fromString($command->plan)
);
$this->userRepository->save($user);
// Despachar Domain Events
foreach ($user->pullDomainEvents() as $event) {
$this->eventBus->publish($event);
}
}
}Multi-Tenancy en Laravel con stancl/tenancy
Para un SaaS multi-tenant, cada cliente necesita aislamiento de datos. La librería stancl/tenancy es el estándar de facto en el ecosistema Laravel.
Instalación:
bash
composer require stancl/tenancy
php artisan tenancy:install
php artisan migrateDos estrategias principales:
| Estrategia | Descripción | Ideal para |
|---|---|---|
| Single Database | Todos los tenants en una DB con tenant_id en cada tabla | SaaS con muchos clientes pequeños |
| Multi Database | Una DB separada por tenant | SaaS con pocos clientes grandes y datos sensibles |
| Multi Schema | Un schema PostgreSQL por tenant | Balance entre las dos anteriores |
Configuración básica del modelo Tenant:
php
// app/Models/Tenant.php
use Stancl\Tenancy\Database\Models\Tenant as BaseTenant;
use Stancl\Tenancy\Contracts\TenantWithDatabase;
use Stancl\Tenancy\Database\Concerns\HasDatabase;
use Stancl\Tenancy\Database\Concerns\HasDomains;
class Tenant extends BaseTenant implements TenantWithDatabase
{
use HasDatabase, HasDomains;
public static function getCustomColumns(): array
{
return ['id', 'plan', 'name', 'trial_ends_at'];
}
}Rutas del tenant vs. rutas centrales:
php
// routes/tenant.php — Solo accesible desde dominio del tenant
Route::middleware(['tenant', 'auth'])->group(function () {
Route::get('/dashboard', DashboardController::class);
Route::apiResource('/projects', ProjectController::class);
});
// routes/web.php — Rutas centrales (registro, billing, login)
Route::middleware('universal')->group(function () {
Route::get('/register', RegisterController::class);
Route::post('/checkout', CheckoutController::class);
});Subscription Billing con Laravel Cashier + Stripe
Ningún SaaS está completo sin un sistema de facturación. Laravel Cashier abstrae toda la complejidad de Stripe en métodos elegantes.
bash
composer require laravel/cashier
php artisan vendor:publish --tag="cashier-migrations"
php artisan migrate
php
// Crear suscripción al completar el onboarding
class CheckoutController extends Controller
{
public function subscribe(Request $request): JsonResponse
{
$tenant = $request->user()->tenant;
$subscription = $tenant
->newSubscription('default', $request->price_id)
->trialDays(14)
->create($request->payment_method);
return response()->json([
'status' => 'subscribed',
'trial_ends' => $subscription->trial_ends_at,
'plan' => $request->price_id,
]);
}
public function upgrade(Request $request): JsonResponse
{
$request->user()->tenant
->subscription('default')
->swap($request->new_price_id);
return response()->json(['status' => 'upgraded']);
}
public function cancel(Request $request): JsonResponse
{
$request->user()->tenant
->subscription('default')
->cancelAtPeriodEnd();
return response()->json(['status' => 'cancellation_scheduled']);
}
}Webhooks de Stripe — Críticos para mantener el estado de suscripción sincronizado:
php
// En routes/web.php
Route::stripeWebhooks('/stripe/webhook');Cashier maneja automáticamente invoice.payment_succeeded, customer.subscription.deleted y invoice.payment_failed sin código adicional.
Testing: La Ventaja Real de la Arquitectura Hexagonal
Aquí es donde la inversión en arquitectura paga dividendos. Con Ports & Adapters puedes testear el 100% de tu lógica de negocio sin base de datos, sin HTTP y sin Stripe.
php
// Tests/Unit/Application/RegisterUserHandlerTest.php
class RegisterUserHandlerTest extends TestCase
{
public function test_registers_user_successfully(): void
{
// Arrange — Usamos repositorio en memoria, no MySQL
$repo = new InMemoryUserRepository();
$eventBus = new FakeEventBus();
$handler = new RegisterUserHandler($repo, $eventBus, new FakePasswordHasher());
$command = new RegisterUserCommand(
email: '[email protected]',
name: 'Rinx Akumura',
plan: 'pro'
);
// Act
$handler->handle($command);
// Assert
$user = $repo->findByEmail(new Email('[email protected]'));
$this->assertNotNull($user);
$this->assertEquals('pro', $user->plan()->value());
$this->assertCount(1, $eventBus->publishedEvents());
}
public function test_throws_exception_when_email_already_exists(): void
{
$this->expectException(UserAlreadyExistsException::class);
$repo = new InMemoryUserRepository();
$repo->save($this->createUser('[email protected]'));
$handler = new RegisterUserHandler($repo, new FakeEventBus(), new FakePasswordHasher());
$handler->handle(new RegisterUserCommand('[email protected]', 'Otro', 'free'));
}
}Los tests corren en milisegundos porque no tocan disco, red ni base de datos. Un suite completo de 200 tests tarda menos de 3 segundos.
Estructura de Carpetas Recomendada
text
src/
├── Domain/
│ ├── User/
│ │ ├── User.php ← Entity
│ │ ├── UserId.php ← Value Object
│ │ ├── Email.php ← Value Object
│ │ ├── UserRepository.php ← Port (interface)
│ │ └── Events/
│ │ └── UserRegistered.php
│ └── Subscription/
│ ├── Subscription.php ← Aggregate Root
│ └── SubscriptionRepository.php
│
├── Application/
│ └── User/
│ └── RegisterUser/
│ ├── RegisterUserCommand.php
│ └── RegisterUserHandler.php
│
└── Infrastructure/
├── Persistence/
│ └── EloquentUserRepository.php ← Adapter
├── Stripe/
│ └── StripeBillingAdapter.php ← Adapter
└── Laravel/
├── Controllers/
└── Providers/
└── AppServiceProvider.php ← Binding ports → adaptersErrores Comunes al Implementar Hexagonal en Laravel
Poner lógica de negocio en los Controllers — Un controller solo debe recibir input, llamar un Use Case y retornar output
Eloquent Models como Domain Entities — Los Eloquent models son adaptadores de infraestructura, no objetos de dominio
Inyectar Request de Laravel en el dominio — El dominio no sabe qué es HTTP; usa Commands como DTOs
Domain Events sincrónicos sin Event Bus — Usa un EventBus desacoplado para que los listeners no bloqueen el flujo principal
Sobre-ingeniería en proyectos pequeños — Esta arquitectura brilla con 3+ developers y dominios complejos; un CRUD simple no la necesita
Herramientas y Recursos Recomendados
stancl/tenancy — Multi-tenancy para Laravel, la librería más completa del ecosistema
Laravel Cashier — Integración oficial de Stripe/Paddle con suscripciones, trials y webhooks
Symfony Messenger — Event Bus robusto compatible con Laravel para Domain Events
PHPStan nivel 8 — Análisis estático estricto que refuerza los contratos de tu dominio
HydraBlack Market — Boilerplates de SaaS con Hexagonal Architecture + DDD + multi-tenancy listos para producción
¿Quieres el Boilerplate Completo?
Implementar esta arquitectura desde cero en un proyecto nuevo toma entre 3 y 5 días de setup. En HydraBlack Market encuentras el boilerplate completo con Hexagonal Architecture, DDD, multi-tenancy con stancl/tenancy, Stripe Cashier y tests unitarios configurados, listo para clonar y construir tu SaaS encima.
→ Descarga el SaaS Boilerplate en HydraBlack Market
Join the Discussion (0)