Arquitectura SaaS en Laravel: estructura escalable para proyectos reales

Laravel sigue siendo una de las apuestas más sólidas para construir un SaaS desde cero: arranque rápido, ergonomía cuidada, ecosistema enorme y una curva de aprendizaje suave. El problema aparece cuando el producto crece. Multi-tenancy, facturación recurrente, módulos por plan, integraciones de terceros, eventos, jobs, pasarelas, webhooks… La estructura por defecto (Models, Http, Services sueltos) se queda corta y los controladores empiezan a engordar como bola de nieve.

Este artículo no es un manifiesto teórico. Es una arquitectura SaaS pensada para Laravel 11/12 que ya está funcionando en proyectos reales con miles de usuarios, suscripciones activas y varios desarrolladores tocando el mismo repositorio sin pisarse. La idea de fondo es muy sencilla: separar responsabilidades de verdad y organizar el código por dominios de negocio, no por tipos de clase.

La idea central: dominios, no carpetas

Un backend SaaS escalable necesita tres propiedades que no se discuten:

  • Modular: dos personas pueden trabajar en paralelo sin colisionar.
  • Predecible: cualquier desarrollador nuevo sabe en menos de 10 minutos dónde vive cada cosa.
  • Escalable: añadir una funcionalidad no obliga a tocar otras cinco.

El atajo para conseguir esas tres cosas es combinar separación de responsabilidades con organización por dominios. En vez de la típica estructura plana:

app/
  Models/
  Http/
  Services/
  Repositories/

Reorganiza por contextos de negocio:

app/
  Domains/
    Users/
    Billing/
    Bookings/
    Notifications/

Cada dominio funciona como una mini aplicación dentro de tu aplicación, con todo lo que necesita para ser autónomo:

app/Domains/Bookings/
  Models/
  Services/
  Repositories/
  DTOs/
  Actions/
  Events/
  Listeners/
  Requests/
  Resources/

Las ventajas son inmediatas. La carga mental baja porque cuando trabajas en reservas no ves nada de facturación. El onboarding mejora porque el árbol de carpetas cuenta el negocio. Y los cambios quedan contenidos: añadir un nuevo flujo en Billing/ no toca Bookings/. Lo que estás construyendo es un monolito modular: la sencillez operativa de un monolito con la disciplina arquitectónica de un sistema con módulos bien definidos. El día que necesites extraer un dominio a un servicio aparte, será una operación quirúrgica, no una refactorización masiva.

Controladores aburridos (y eso es bueno)

El controlador no es el sitio donde vive la lógica. Su único trabajo es traducir HTTP a una llamada a tu dominio y devolver la respuesta. Si en una revisión de código ves un controlador con validaciones, queries y reglas de negocio mezcladas, es señal segura de que el problema te está esperando un par de sprints más adelante.

Mal:

public function store(Request $request)
{
    // validación inline
    // lógica de negocio
    // queries directas a Eloquent
    // envío de emails, jobs, etc.
}

Bien:

public function store(StoreBookingRequest $request)
{
    $booking = $this->bookingService->create($request->validated());

    return BookingResource::make($booking);
}

Tres responsabilidades como mucho:

  1. Aceptar la request (con un FormRequest que valida).
  2. Llamar al servicio del dominio.
  3. Devolver la respuesta (con un Resource que controla la forma).

Los servicios son el cerebro

La capa de servicios concentra la lógica de negocio. No es una capa decorativa: es la que justifica que la API, los comandos de consola y los jobs encolados puedan compartir el mismo código sin duplicarlo.

class BookingService
{
    public function __construct(
        private BookingRepository $repository,
        private NotificationService $notifications,
    ) {}

    public function create(array $data): Booking
    {
        $this->validateAvailability($data);

        $booking = $this->repository->create($data);

        $this->notifications->bookingCreated($booking);

        return $booking;
    }
}

¿Por qué merece la pena este nivel de indirección?

  • Reutilización real: el mismo create() sirve a un endpoint REST, a un comando Artisan que importa reservas en lote y a un job de webhook entrante.
  • Tests directos: puedes probar el servicio sin levantar el ciclo HTTP.
  • Lógica centralizada: si mañana cambia la regla de disponibilidad, hay un único sitio que tocar.

Repositorios para acceso a datos limpio

Llamar a Eloquent desde cualquier sitio funciona hasta que descubres que tienes seis variantes de la misma query repartidas por servicios, controladores y comandos. La capa de repositorio resuelve eso poniendo nombre a las consultas relevantes del dominio.

En vez de:

Booking::where('status', 'pending')
    ->where('starts_at', '>=', now())
    ->with(['user', 'service'])
    ->orderBy('starts_at')
    ->get();

Tienes:

$this->bookings->upcomingPending();

Beneficios:

  • Aísla la persistencia: el día que pase de Eloquent a un read model en ClickHouse, los servicios no se enteran.
  • Las queries con nombre documentan el dominio mejor que cualquier wiki.
  • Los servicios quedan centrados en reglas de negocio, no en sintaxis ORM.

API-first: no es opcional

Si tu SaaS va a tener un frontend en React, Next.js, Vue o una app móvil, la API es el contrato. Cuidarlo desde el día uno te ahorra cambios incompatibles más adelante.

Versiona la API

/api/v1/bookings
/api/v2/bookings

Tener v1 desde el principio es mucho más barato que migrar un cliente móvil que ya está en producción.

Usa API Resources

return BookingResource::make($booking);

El recurso es el sitio donde decides qué campos expones y cómo se llaman. Sin él, cualquier cambio en la base de datos puede romper el front sin avisar.

Form Requests para validación

class StoreBookingRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user()->can('create', Booking::class);
    }

    public function rules(): array
    {
        return [
            'service_id' => ['required', 'exists:services,id'],
            'starts_at'  => ['required', 'date', 'after:now'],
            'notes'      => ['nullable', 'string', 'max:500'],
        ];
    }
}

Validación reutilizable, autorización en el mismo sitio y controladores limpios.

Lo que un SaaS no se puede permitir hacer mal

Autenticación

Para SaaS modernos con SPA o app móvil, Laravel Sanctum cubre el 90% de los casos: tokens API, autenticación de SPA mediante cookies y un setup mínimo. Passport tiene sentido cuando necesitas OAuth2 completo (proveedor de identidad para terceros, integraciones con apps externas que no controlas). Empieza por Sanctum y migra solo si el caso de uso lo justifica.

Multi-tenancy

Hay dos estrategias dominantes y la elección marca la arquitectura entera:

Estrategia

Cómo funciona

Cuándo usarla

Riesgos

BBDD compartida con tenant_id

Una sola base de datos. Cada tabla relevante lleva un tenant_id y un global scope filtra automáticamente.

La mayoría de SaaS B2B y B2C. Más barato, más simple, más rápido de operar.

Un global scope mal aplicado o una query cruda sin filtro = fuga de datos entre tenants. Auditoría obligatoria.

BBDD por tenant

Cada cliente tiene su propia base de datos. Conexión dinámica por tenant.

Clientes enterprise, requisitos regulatorios fuertes (sanidad, banca), o tenants con volúmenes muy desiguales.

Migraciones más complejas, backups por tenant, coste operativo mayor.

El paquete stancl/tenancy resuelve la mayoría del andamiaje en ambos modelos: identificación por dominio o subdominio, cambio de conexión, jobs y eventos por tenant, y aislamiento de caché. No reinventes esto desde cero.

Suscripciones y facturación

No construyas tu propio motor de facturación. Laravel Cashier con Stripe (o Paddle) te da suscripciones, periodos de prueba, planes con tiers, cupones, pruebas gratuitas, prorrateos, facturas en PDF y gestión de impagados sin escribir prácticamente nada. Construir eso desde cero significa meses de desarrollo y un riesgo permanente de bug en cobros, que es exactamente el sitio donde no quieres tener bugs.

$user->newSubscription('default', 'price_pro_monthly')
     ->trialDays(14)
     ->create($paymentMethod);

Rendimiento y escalabilidad

Colas: nunca bloquees una request

Cualquier tarea que tarde más de 200 ms y no sea estrictamente necesaria para la respuesta debe ir a una cola. Email, generación de PDFs, sincronización con APIs externas, cálculos pesados, recálculos de métricas… todo a Redis + Laravel Queues.

SendBookingEmailJob::dispatch($booking)->onQueue('emails');

Separar en colas por prioridad (emails, webhooks, reports) te permite escalar workers de forma independiente.

Rate limiting

Route::middleware('throttle:60,1')->group(function () {
    Route::apiResource('bookings', BookingController::class);
});

Rate limit por usuario autenticado, por IP o por API key. En SaaS, además, conviene que el límite refleje el plan: el plan Free no debería tener el mismo throttle que el Enterprise.

Cache estratégico

Cachear todo es un anti-patrón. Cachea lo que es caro de calcular y barato de invalidar: configuración de planes, permisos de roles, agregados de dashboard, listas de catálogo. Y siempre con tags o claves predecibles para poder invalidar sin barrer toda la cache.

Observabilidad

Cuando algo falla en producción a las 3 de la mañana, lo único que importa es lo rápido que puedes diagnosticar. Mínimos:

  • Laravel Telescope en local y staging.
  • Sentry (o Bugsnag) en producción para excepciones y trazas.
  • Logs estructurados en JSON enviados a Loki, Datadog o CloudWatch.
  • Horizon si usas Redis para colas: visibilidad de jobs, throughput y errores en tiempo real.

Estructura final de referencia

app/
  Domains/
    Users/
      Models/
      Services/
      Repositories/
      Requests/
      Resources/
      Actions/
    Bookings/
      Models/Booking.php
      Services/BookingService.php
      Repositories/BookingRepository.php
      Resources/BookingResource.php
      Requests/StoreBookingRequest.php
      Events/BookingCreated.php
      Listeners/SendBookingNotification.php
    Billing/
      Services/SubscriptionService.php
      Listeners/HandleStripeWebhook.php
    Notifications/
      Channels/
      Templates/

  Http/
    Controllers/
      Api/
        V1/
          BookingController.php
          UserController.php
    Middleware/
      EnsureTenant.php

routes/
  api.php
  web.php

tests/
  Feature/Bookings/
  Unit/Bookings/

El controlador vive en Http/ porque pertenece a la capa de transporte HTTP, no al dominio. Pero todo lo demás (modelos, servicios, repositorios, eventos) vive dentro de su dominio.

Errores que aparecen una y otra vez

Síntoma

Causa habitual

Solución

Controladores de 300 líneas

Lógica de negocio escrita ahí mismo

Mover a un Service del dominio correspondiente

La misma query repetida en 5 sitios

Sin capa de repositorio

Crear método con nombre en el repositorio del dominio

Romper clientes móviles al cambiar la API

Sin versionado y sin Resources

Versionar (/api/v1/) y devolver siempre con Resource

Cobros incorrectos

Facturación a medida

Migrar a Cashier + Stripe/Paddle

Fugas de datos entre tenants

Queries crudas sin global scope

Auditoría + tests automáticos por tenant

Requests lentas que bloquean al usuario

Tareas pesadas en el ciclo HTTP

Encolar con jobs y procesar con workers

En resumen

Laravel escala perfectamente bien para SaaS, pero solo si lo estructuras correctamente desde el primer día. Organizar por dominios, mantener controladores delgados, concentrar la lógica en servicios, aislar la persistencia con repositorios, versionar la API, apoyarse en Sanctum y Cashier para los problemas resueltos, y mover todo lo pesado a colas: con esa base, el código aguanta crecimiento real sin convertirse en una montaña de deuda técnica.

Lo contrario también es cierto. Si arrancas con todo apilado en Models/ y Http/Controllers/, vas a pasar los siguientes meses refactorizando en lugar de añadir funcionalidad. Y los SaaS no se ganan refactorizando, se ganan enviando.

Preguntas frecuentes

¿Es necesario usar repositorios en Laravel?
No es obligatorio, pero en un SaaS de tamaño medio-grande la disciplina compensa. En proyectos pequeños o prototipos, llamar a Eloquent directamente es perfectamente razonable.

¿Sanctum o Passport para autenticación?
Sanctum cubre la mayoría de los casos modernos (SPA, móvil, tokens API). Pasa a Passport solo si necesitas un OAuth2 completo como proveedor de identidad para aplicaciones de terceros.

¿BBDD compartida o BBDD por tenant?
Compartida con tenant_id para la mayoría de SaaS B2B/B2C. BBDD por tenant cuando hay requisitos de aislamiento fuerte (regulatorios) o clientes enterprise con volúmenes muy desiguales.

¿Cuándo es buen momento para extraer un dominio a un microservicio?
Cuando ese dominio escala con un perfil de carga radicalmente diferente al resto, cuando un equipo independiente lo va a mantener, o cuando el ciclo de despliegue choca con el del resto de la aplicación. Antes de eso, el monolito modular suele ganar.

¿Conviene escribir mi propio sistema de facturación?
No. Cashier con Stripe o Paddle resuelve suscripciones, prorrateos, periodos de prueba, cupones y facturas con una fracción del esfuerzo y mucho menos riesgo de bugs en el cobro.

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP