Base de datos por tenant en Laravel 12: cómo Zenpresa aísla cada negocio y distribuye módulos como paquetes de Composer
La primera decisión de arquitectura en Zenpresa fue la que rechazamos. La forma obvia de construir un SaaS multi-tenant en Laravel es una columna tenant_id en cada tabla, un scope global que añade WHERE tenant_id = ? a cada consulta, y la esperanza de que nadie escriba nunca una consulta que se salte el scope. La mayoría de las plataformas se construyen así. La mayoría de ellas, en algún momento, le han mostrado a un cliente los datos de otro.
Nosotros elegimos darle a cada negocio su propia base de datos. Este artículo trata de por qué, qué cuesta, cómo encaja el sistema de módulos encima, y la única clase de bug que sobrevive incluso cuando el aislamiento está bien hecho.
Por qué una base de datos por tenant
Tres razones, en orden de la frecuencia con que aparecen.
Radio de impacto. Un bug en un sistema de esquema compartido es un bug cross-tenant por defecto. Un scope olvidado, una consulta raw, un join que trae el lado equivocado, una herramienta de administración con un comodín: cada uno de estos filtra datos entre clientes. En un sistema de base de datos por tenant esos mismos bugs devuelven las filas equivocadas dentro de un negocio. Malo, pero recuperable, y nunca una notificación de brecha de seguridad.
Copia de seguridad y restauración por cliente. Cuando un negocio llama y dice “borramos un año de facturas por accidente”, la respuesta en un esquema compartido es una restauración quirúrgica y cuidadosa desde un snapshot de una base de datos que también contiene a todos los demás clientes. Con una base de datos por negocio, la respuesta es restaurar una base de datos. La exportación al marcharse es la misma historia: un dump de una base de datos, no un extracto filtrado con revisión legal.
Sin la clase de bug del scope olvidado. Esta es la que más nos importa. En un esquema compartido, el aislamiento de tenants es una propiedad que tienes que restablecer en cada consulta durante toda la vida del código. En un sistema de base de datos por tenant es una propiedad de la conexión. Una vez que la petición está vinculada a un tenant, cada modelo Eloquent, cada sentencia raw y cada job encolado que hereda ese contexto está aislado por construcción. Los ingenieros nuevos no pueden equivocarse, porque no hay nada que acertar.
Usamos el paquete stancl/tenancy en Laravel 12 con Postgres, y es el que hace el trabajo pesado de crear una base de datos en el registro, cambiar la conexión por petición y ejecutar migraciones en todos los tenants.
Base de datos central vs bases de datos de tenant
Hay dos tipos de base de datos en el sistema.
La base de datos central guarda lo que existe por encima de cualquier negocio individual: el registro de tenants, los dominios que mapean a cada tenant, el estado de suscripción y facturación de la propia plataforma, los usuarios y roles a nivel de plataforma, el catálogo de módulos y el rastro de auditoría de quién aprovisionó qué. Si una tabla describe la relación entre un negocio y la plataforma, vive aquí.
Cada base de datos de tenant guarda todo lo que posee el negocio: clientes, citas, servicios, personal, facturas, pagos, páginas del sitio web, marca, plantillas de correo, el historial de conversaciones del asistente, etcétera. Si una tabla se exportaría cuando el negocio se marcha, vive aquí.
El conjunto de migraciones está dividido de la misma forma. Las migraciones centrales se ejecutan una vez. Las migraciones de tenant se ejecutan una vez por base de datos de tenant, y un despliegue que añade una columna a facturas ejecuta esa migración en cada negocio en secuencia. La división se impone por directorio, así que una migración no puede terminar accidentalmente en el lado equivocado.
Resolver el tenant por petición
Cada tenant tiene al menos un dominio: un subdominio en nuestra plataforma por defecto, y un dominio personalizado una vez que conecta uno. El pipeline de la petición busca el host, encuentra el tenant y cambia la conexión de base de datos por defecto antes de que se ejecute cualquier código de aplicación. Los controladores, modelos y servicios nunca mencionan al tenant. Consultan “la base de datos” y la base de datos es la correcta.
La parte sutil es todo lo que no se ejecuta dentro de una petición. Los jobs encolados, los comandos programados, los listeners de eventos disparados después de la respuesta y el bucle de herramientas del asistente de IA necesitan saber a qué tenant pertenecen. El paquete de tenancy serializa el identificador del tenant en cada job encolado y reinicializa la conexión cuando el job se ejecuta, de modo que un job de “enviar recordatorio de factura” despachado dentro de la petición del tenant A se ejecuta contra la base de datos del tenant A una hora después. Para el trabajo programado que abarca varios tenants, un comando itera el registro e inicializa cada tenant por turno.
La regla que imponemos en code review es simple: si toca datos de tenant y no está en una petición, debe estar dentro de un contexto de tenant, y el test debe demostrarlo.
Módulos como paquetes de Composer independientes
Zenpresa vende funcionalidades à la carte, así que el código está organizado de la misma manera. Cada módulo — agenda, facturación, pagos, hub de clientes, marketing, creador de sitios web, asistente de IA y varios más pequeños — es un paquete de Composer independiente que vive en un directorio modules/, conectado mediante un path repository. Cada uno tiene su propio composer.json, su propio namespace y su propio service provider que Laravel descubre automáticamente.
La aplicación en sí sigue siendo un único desplegable: un proceso Laravel, una imagen de contenedor, un despliegue. Esto es un monolito modular, no microservicios. Obtenemos los beneficios organizativos de los módulos (propiedad clara, límites claros, la capacidad de extraer un módulo más adelante) sin el costo operativo de los sistemas distribuidos.
Beneficios prácticos que no esperábamos:
- PHPStan y la suite de tests se ejecutan por módulo. Un cambio en facturación ejecuta primero los tests de facturación. Los tests entre módulos existen, pero son un nivel separado y más lento.
- La dirección de las dependencias es visible. El
composer.jsonde un módulo declara de qué depende. Si marketing necesita clientes, esa es una dependencia declarada. Si clientes empezara a necesitar marketing, el ciclo aparecería en el manifiesto, no seis meses después en una sesión de depuración. - La extracción es una opción real. No hemos extraído ningún módulo a un servicio separado, y puede que nunca lo necesitemos. Pero los límites ya están trazados, lo que vale más que la extracción en sí.
Qué posee un módulo
Un módulo posee todo lo relativo a su funcionalidad:
- Rutas para sus endpoints de API, registradas por su service provider bajo el prefijo del módulo.
- Migraciones de tenant para sus tablas, descubiertas desde el propio directorio de migraciones del módulo.
- Modelos, servicios y jobs en el namespace del módulo.
- Páginas de frontend en el directorio React correspondiente, cargadas de forma diferida para que un tenant sin el módulo nunca descargue su código.
- Herramientas de IA que el asistente puede llamar, cada una etiquetada con la clave del módulo para que solo aparezcan cuando el módulo está habilitado.
- Una clave de funcionalidad que lo une todo.
Habilitar un módulo para un tenant es un flag de suscripción. Un middleware comprueba el flag en cada petición a una ruta del módulo y devuelve una respuesta clara de “módulo no habilitado” en lugar de un 404. El frontend lee los mismos flags para decidir qué elementos de navegación y páginas mostrar. El registro de herramientas de IA los lee para decidir qué herramientas puede ver el modelo. Un flag, tres puntos de aplicación, todos leyendo de la misma fuente de verdad.
Trade-offs que aceptamos
La base de datos por tenant no es gratis, y sería deshonesto presentarla como tal.
Migraciones sobre N bases de datos. Un despliegue que cambia una tabla de tenant ejecuta la migración contra cada tenant. Con un número modesto de tenants esto tarda segundos. Con muchos miles tarda un tiempo real y necesita ser resiliente a un fallo a mitad de camino. Ejecutamos las migraciones de tenant como un job encolado y reanudable que registra el progreso por tenant, y diseñamos las migraciones para que sean aditivas, de modo que una flota parcialmente migrada siga siendo funcional.
Los reportes cross-tenant son más difíciles. “¿Cuántas citas se reservaron en toda la plataforma el mes pasado?” es una línea en un esquema compartido y un fan-out en el nuestro. Empujamos los agregados a nivel de plataforma a la base de datos central de forma programada en lugar de consultar los tenants en vivo. El intercambio vale la pena: los reportes son una preocupación programada; el aislamiento es una preocupación de cada petición.
Gestión de conexiones. Las conexiones de Postgres no son gratis, y un único proceso que sirve a muchos tenants durante la vida de un worker necesita reutilizar las conexiones con cuidado. El paquete de tenancy se encarga del cambio; nosotros tuvimos que ajustar los tamaños de pool y asegurarnos de que los workers de larga duración no filtren una conexión por cada tenant que tocan.
El desarrollo local es más pesado. Un entorno de desarrollo nuevo tiene que aprovisionar al menos una base de datos de tenant antes de que nada funcione. Lo envolvimos en un único comando, pero sigue siendo más que migrate --seed.
La clase de bug que sobrevive
El aislamiento por conexión elimina el bug del scope olvidado de las tablas de tenant. No lo elimina de las tablas centrales con alcance de tenant.
Algunos datos son legítimamente centrales pero pertenecen a un tenant específico: los dominios del tenant, su suscripción, sus claves de API a nivel de plataforma, el registro de auditoría de su aprovisionamiento. Esas filas viven en la base de datos central con una columna de identificador de tenant, y cualquier consulta contra ellas desde dentro de un contexto de tenant sigue necesitando el predicado. El paquete de tenancy no puede ayudar aquí porque la conexión central, por diseño, no está vinculada a un tenant.
Esta es exactamente la forma de bug que un fixture de test con un solo tenant no puede detectar. Un test que aprovisiona un tenant, consulta la tabla central de dominios y afirma que el resultado contiene el dominio del tenant pasará tanto si el predicado está presente como si no, porque solo hay datos de un tenant que devolver. El test debe aprovisionar dos tenants y afirmar que las filas del segundo no aparecen. Ahora tenemos un caso de test base que hace exactamente eso para cada tabla central con columna de tenant, y un elemento en la checklist de revisión que pregunta “¿estos datos centrales tienen alcance de tenant, y el test demuestra que el otro tenant queda excluido?”
La lección se generaliza más allá de nuestro stack: sea cual sea tu frontera de aislamiento, encuentra los datos que viven fuera de ella, y prueba esos datos con dos tenants, nunca con uno.
Dónde rinde esto más adelante
Dos decisiones que se derivan directamente de la arquitectura se han pagado solas varias veces.
La primera es el asistente de IA. Como la conexión está vinculada al tenant, cada herramienta que el asistente llama está aislada por construcción. La herramienta no filtra por tenant; simplemente no puede alcanzar la base de datos de otro tenant. Eso nos permitió centrar el trabajo de seguridad del asistente en los problemas que realmente quedan, que cubren los siguientes artículos de esta serie.
La segunda es el control de costos. La medición por tenant del uso del asistente, los límites de tasa por tenant y los topes por tenant son todos naturales cuando el tenant es un concepto de runtime de primera clase en lugar de una columna. Los patrones de nuestro artículo anterior sobre limitación de tasa y control de costos de API LLM encajan sin adaptación.
Si estás construyendo una plataforma donde los datos de los clientes nunca deben tocarse, y puedes permitirte la sobrecarga operativa, tomaríamos la misma decisión otra vez. Si estás construyendo una app de consumo con millones de cuentas diminutas, probablemente no deberías. La respuesta correcta depende de lo que costaría una fuga, y para los negocios a los que sirve Zenpresa, la respuesta es: todo.
Siguiente en la serie: Construyendo un asistente agéntico en Laravel con el SDK de PHP de Anthropic: herramientas, model lanes, prompt caching y medición por tenant
FAQ: base de datos por tenant en Laravel
¿Es la base de datos por tenant más lenta que un esquema compartido?
Por petición, no. Una vez cambiada la conexión, las consultas golpean una base de datos más pequeña con un working set más pequeño, lo que suele ser más rápido que una tabla compartida con un predicado de tenant en cada índice. Los costos están en otra parte: las migraciones se ejecutan una vez por tenant, los reportes cross-tenant necesitan un fan-out o un agregado programado, y el pooling de conexiones requiere cuidado en workers de larga duración.
¿Cómo saben los jobs encolados a qué tenant pertenecen?
La capa de tenancy serializa el identificador del tenant en cada job despachado desde dentro de un contexto de tenant y reinicializa el tenant, incluida la conexión de base de datos, cuando el job se ejecuta. La regla que imponemos es que cualquier código que toque datos de tenant fuera de una petición debe ejecutarse dentro de un contexto de tenant explícito, y su test debe demostrarlo.
¿Qué es un monolito modular y por qué no microservicios?
Un monolito modular es una única aplicación desplegable cuyo código está organizado en módulos con límites estrictos, en nuestro caso paquetes de Composer independientes con sus propias rutas, migraciones, modelos y service providers. Obtenemos propiedad clara y dirección de dependencias sin la sobrecarga de los sistemas distribuidos. La extracción a servicios separados sigue siendo posible porque los límites ya existen.
¿Elimina la base de datos por tenant las fugas de datos entre tenants?
Para los datos en bases de datos de tenant, sí, por construcción. Para las tablas centrales que llevan un identificador de tenant — dominios, suscripciones, claves de API de plataforma — el predicado sigue siendo necesario y el paquete de tenancy no puede imponerlo. Prueba esas tablas con dos tenants y afirma que las filas del segundo quedan excluidas; un fixture de un solo tenant pasará exista o no el filtro.
¿Cómo manejan una migración que falla a mitad de camino en miles de bases de datos de tenant?
Las migraciones de tenant se ejecutan como un job encolado y reanudable que registra el progreso por tenant, así que un fallo se reanuda desde el tenant que falló en lugar de empezar de nuevo. Las migraciones están diseñadas para ser aditivas, de modo que una flota parcialmente migrada siga sirviendo peticiones correctamente mientras el job se pone al día.
Artículos Relacionados
Construyendo un asistente agéntico en Laravel con el SDK de PHP de Anthropic: herramientas, model lanes, prompt caching y medición por tenant
El asistente de IA de Zenpresa sobre el SDK de PHP de Anthropic en Laravel: contrato de herramientas, model lanes, prompt caching, SSE y medición por tenant.
Presentamos Zenpresa: todo lo que necesita tu negocio de servicios, bajo tu propia marca
Zenpresa es una plataforma modular para negocios de servicios: agenda, facturación, pagos, clientes y sitio web bajo tu marca, con asistente de IA integrado.
Checks de CI que los linters no pueden hacer: contratos de API, patrones móviles, ratchets de warnings y tests que fijan bugs de Stripe
Cuatro checks de CI que Zenpresa añadió tras bugs reales: contrato de API, patrones móviles en JSX, ratchet de warnings y tests que fijan bugs de Stripe.