Multi-tenancy en Laravel 13: cómo aislar los datos de cada cliente en tu SaaS
Cuando tu app Laravel pasa de servir a un cliente a servir a varios, el aislamiento de datos deja de ser opcional: un tenant no puede ver las facturas del otro. En Laravel 13 hay tres patrones probados — columna tenant_id con scopes, base de datos por tenant con stancl/tenancy o schema por tenant en PostgreSQL — y esta guía recorre la decisión y el código de principio a fin, con tests que demuestran el aislamiento.
Qué es multi-tenancy y por qué tu SaaS lo necesita
El problema: una app, varios clientes, cero fugas de datos
Multi-tenancy es la arquitectura donde una misma aplicación sirve a varias empresas o clientes, los tenants, manteniendo sus datos completamente separados. Es el punto donde muchos SaaS se atascan: la app funciona para un cliente, y cuando llega el segundo aparece la tentación de copiar la base de datos o empezar a mezclar registros. El patrón multi-tenant resuelve eso de forma sistemática: cada tenant opera su propio mundo aunque el código, los servidores e incluso la base de datos sean compartidos.
Tenant, central data y la frontera entre plataforma y cliente
Antes de escribir código conviene separar dos tipos de información. La central data pertenece a la plataforma: el propio registro del tenant, su plan, su estado de pago. Los datos de tenant son los del negocio de cada cliente: sus clientes, sus facturas, sus configuraciones. Confundir esa frontera es la causa número uno de fugas de datos, porque un modelo mal ubicado termina mezclando lo global con lo particular.
Identificar al tenant: subdominio, dominio propio o ruta
Cada petición HTTP debe responder a una pregunta: de qué tenant hablamos. La forma más común es el hostname, con un subdominio por cliente como acme.app.test, aunque también se soportan dominios propios de cada empresa y, en casos sencillos, un parámetro en la ruta. El mecanismo que resuelve esa pregunta se llama identificación del tenant y suele vivir en un middleware.
Los tres patrones de aislamiento
Single database con tenant_id y Global Scopes
El patrón más barato comparte una sola base de datos: cada tabla tenant-scoped lleva una columna tenant_id y las queries se filtran siempre por el tenant actual. Su gran ventaja es la simplicidad operativa; su gran exigencia, la disciplina, porque cualquier query que olvide el filtro filtra datos entre empresas.
Base de datos por tenant: aislamiento fuerte a cambio de operación
El patrón opuesto da a cada tenant su propia base de datos. El aislamiento es físico, el backup y la restauración se hacen por cliente, y los contratos enterprise se firman con otra tranquilidad. El costo es operativo: las migraciones, las colas y el mantenimiento se multiplican por cada inquilino, y por eso casi siempre se automatiza con un paquete.
Schema por tenant con PostgreSQL (y cuándo mirar RLS)
Entre ambos extremos existe un punto intermedio: una sola base de datos con un schema por tenant, algo que PostgreSQL maneja de forma nativa y que algunos paquetes de Laravel soportan con un database manager específico. Si además necesitas garantías a nivel de servidor, el row-level security de PostgreSQL permite que la propia base de datos imponga el filtro por tenant.
Cómo elegir según etapa del SaaS, costo y cumplimiento
No hay un patrón correcto universal, hay uno correcto para cada etapa. Con pocos clientes y presupuesto ajustado, el nivel de fila con tenant_id es suficiente. Cuando el contrato exige aislamiento fuerte, backups por cliente o cumplimiento estricto, la base de datos por tenant se justifica. La regla de oro: elige según la realidad del producto y sus obligaciones, no según la moda.
Opción A: nivel de fila a mano, sin paquetes
La columna tenant_id y el índice compuesto
El nivel de fila empieza en las migraciones: cada tabla de tenant lleva su tenant_id, y el índice más importante no es el de la columna suelta sino el compuesto que la pone a la cabeza, por ejemplo con el id de la factura. Ese índice líder hace que todas las queries del tenant apoyen en el filtro correcto.
Global Scope de Eloquent: el cinturón de seguridad de cada query
Para que ningún query olvide el filtro, registras un Global Scope en cada modelo tenant-scoped: Eloquent lo añade automáticamente a todas las consultas de ese modelo, como un where invisible que siempre sabe de qué tenant hablamos. Es el cinturón de seguridad del patrón de fila, porque convierte el error humano en imposible para las queries normales del modelo.
Middleware para resolver el tenant desde el subdominio
El cinturón necesita saber quién es el tenant actual. Un middleware lee el subdominio de la petición, busca el tenant correspondiente y lo deja disponible para el resto de la aplicación, por ejemplo como un singleton de tenant o en el contenedor. Esa pieza se ejecuta antes de los controladores y define el contexto de toda la request.
Defensa en profundidad: por qué el scope solo no basta
El Global Scope cubre las queries de Eloquent de ese modelo, pero no todo: una query cruda con DB, una relación cargada por una vía que no pasa por el modelo o un job que corre fuera de la request pueden escapar del filtro. Por eso la práctica recomendada es defensa en profundidad: scope más middleware de resolución más tests de aislamiento que demuestren que el tenant A jamás ve datos del tenant B.
Opción B: base de datos por tenant con stancl/tenancy
Instalar el paquete: composer require stancl/tenancy
Cuando el aislamiento fuerte se vuelve un requisito, el estándar de la comunidad es stancl/tenancy, mantenido hoy como archtechx/tenancy. Su promesa es multi-tenancy automático: no necesitas tocar los modelos para cambiar de conexión ni reemplazar las clases de Laravel con versiones especiales. La instalación arranca con el require del paquete y la publicación de su configuración.
php artisan tenancy:install y qué genera en tu app
Tras instalar el paquete, ejecutas php artisan tenancy:install: el comando publica la configuración en config/tenancy.php, crea las migraciones de la plataforma y deja preparada la estructura para separar ambos mundos. A partir de ahí conviene revisar la configuración publicada antes de tocar nada más, porque ahí se decide qué conexiones usan la central database y cuáles las bases de cada tenant.
Central database vs tenant databases: migraciones separadas
El modelo mental del paquete tiene dos carriles de migraciones: las de la plataforma, que crean la central database con la tabla de tenants, sus planes y sus datos globales; y las del tenant, que definen el esquema de negocio que cada cliente tendrá en su propia base. Mezclar ambos carriles es el error típico del primer día: una migración de tenant en el carril central deja a todos los clientes sin su tabla.
Definir el modelo Tenant y crear el primer tenant
El modelo Tenant representa a cada cliente y se conecta a la central database. Crear el primer tenant es crear un registro de ese modelo con su nombre de dominio: el paquete se encarga de preparar su base de datos cuando llega el momento de migrar. A partir de ese registro, la aplicación sabe que acme.app.test y globex.app.test son mundos separados.
php artisan tenants:migrate para llevar el esquema a cada tenant
Con las migraciones de tenant escritas, el comando php artisan tenants:migrate las aplica a todas las bases de datos de los tenants, y admite seleccionar destinatarios concretos con una lista de identificadores cuando solo quieres actualizar a un cliente. El mismo patrón se repite con los seeders mediante tenants:seed si cada inquilino necesita datos iniciales.
Identificación y rutas del tenant
Hostname identification: subdominios y dominios de segundo nivel
El paquete identifica al tenant por hostname: cada petición se resuelve al tenant cuyo dominio coincide, y el mecanismo soporta tanto subdominios de tu plataforma como dominios de segundo nivel propios del cliente, el típico caso donde la empresa trae su propio dominio a tu SaaS.
Proteger rutas y middleware del tenant
Las rutas del mundo tenant se agrupan bajo el middleware que activa la conexión correcta. En Laravel defines el grupo con Route::domain para casar el subdominio, y dentro de él el middleware del paquete se encarga de cambiar la conexión de base de datos, el cache y el storage al contexto del tenant antes de llegar al controlador. Lo que queda fuera de ese grupo es plataforma pura y no debe tocar datos de cliente.
Qué pasa con colas, caché y storage en un mundo por tenant
La base de datos es solo la primera capa: colas, caché y archivos también pueden filtrar información entre tenants si se comparten sin separación. El paquete ofrece mecanismos para que estas piezas sean tenant-aware cuando lo necesitas, y la decisión de compartirlas o separarlas debe tomarse por pieza: una caché compartida con claves por tenant puede ser aceptable, un storage compartido sin particionar casi nunca lo es.
La alternativa esencial: spatie/laravel-multitenancy
Tenant finder y migraciones landlord/tenant separadas
Si el peso de stancl/tenancy no se justifica, spatie/laravel-multitenancy ofrece lo esencial: un tenant finder que determina qué tenant corresponde a cada petición, migraciones landlord y tenant separadas, y la lógica justa para que la app sepa en qué contexto corre. Es la opción mínima para quien quiere entender cada pieza sin magia.
Cuándo elegir Spatie en vez de stancl/tenancy
La elección depende del aislamiento que necesites. Spatie brilla cuando el patrón de fila con tenant_id te queda corto pero todavía no necesitas una base por tenant con toda su operación, o cuando prefieres construir la resolución y la conexión con tus propias manos. stancl/tenancy gana cuando quieres base de datos por tenant automática, con su identificación por hostname, sus migraciones por cliente y su ecosistema ya resuelto.
Probar el aislamiento con Pest
Test de resolución del tenant por subdominio
Los tests de aislamiento son la prueba de que la arquitectura funciona. El primero verifica la resolución: una petición a acme.app.test activa el tenant Acme y una a globex.app.test activa Globex, nunca al revés. Si el middleware falla, este test lo caza antes que cualquier cliente.
Test de que el tenant A no ve datos del tenant B
El test que da valor al SaaS es el de no-fuga: con dos tenants creados y datos propios en cada uno, una query de Eloquent ejecutada en el contexto del tenant A devuelve solo las filas de A, y una ruta por el subdominio de A nunca expone registros de B. Ese test se escribe una vez y se queda para siempre como red de seguridad contra el día en que alguien olvide un filtro.
Test de migraciones aplicadas a un tenant concreto
El tercer test cubre la operación: verifica que las migraciones de tenant se aplican a la base del tenant indicado y que su esquema contiene las tablas de negocio esperadas. Así detectas a tiempo el error clásico de una migración nueva que se quedó en el carril equivocado.
Conclusión
El multi-tenancy en Laravel 13 se decide antes de escribirse: nivel de fila con tenant_id y Global Scopes para arrancar barato, base de datos por tenant con stancl/tenancy cuando el aislamiento fuerte y el backup por cliente son requisito, y schema por tenant en PostgreSQL como punto intermedio. Elijas lo que elijas, el aislamiento se demuestra con tests, no con buenas intenciones: resolución del tenant por subdominio, no-fuga entre clientes y migraciones bien aplicadas. Con esa tríada, tu app puede crecer de un cliente a cien sin que los datos de uno aparezcan en el panel del otro.