Toda conversación sobre multi-tenancy empieza en el lugar equivocado: “¿una base de datos por tenant o una compartida?”
Es una pregunta legítima, pero no es la que te muerde. Lo que te muerde seis meses después es la consulta que alguien escribió a las 11 de la noche y que olvidó el where('tenant_id', ...), el job en cola que corrió con el tenant equivocado, y la llave de caché que le sirvió el dashboard del cliente A al cliente B.
El multi-tenancy no es una decisión de esquema. Es un problema de disciplina, y los patrones de abajo son a los que vuelvo siempre porque hacen que esa disciplina sea automática en lugar de opcional.
Primero, elige la opción aburrida
Hay tres modelos comunes:
| Modelo | Aislamiento | Costo operativo |
|---|---|---|
| Una BD por tenant | El más fuerte | El más alto — migraciones, respaldos y conexiones ×N |
| Un esquema por tenant | Fuerte | Alto, y muy atado al motor de base de datos |
BD compartida + tenant_id | El más débil por defecto | El más bajo |
La industria sigue romantizando la BD por tenant. Y sí, te da la mejor historia de aislamiento y la conversación más fácil de “exporten mis datos”. También te da N migraciones que correr en cada deploy, N respaldos que verificar, y un pool de conexiones que se pone interesante a partir de unos cuantos cientos de tenants.
Empieza con base de datos compartida y una columna tenant_id. Muévete a BD por tenant cuando un requisito concreto te obligue: una cláusula de cumplimiento, un cliente lo bastante grande como para merecer su propio radio de explosión, un problema de vecino ruidoso que ya mediste. No antes.
El resto del post asume el modelo compartido, porque ahí es donde la disciplina importa más.
Patrón 1: resuelve el tenant una sola vez, en la orilla
La resolución del tenant va en un solo lugar — un middleware — y el resultado va en el contenedor, no en una global.
class ResolveTenant
{
public function handle(Request $request, Closure $next): Response
{
$tenant = Tenant::query()
->where('subdomain', $request->getHost())
->firstOrFail();
app()->instance(Tenant::class, $tenant);
return $next($request);
}
}
Subdominio, prefijo de ruta o un claim en el token: elige uno y sé consistente. Lo que no debes hacer es dejar que cada controlador deduzca el tenant de lo que traiga el request. En el momento en que haya dos formas de responder “¿quién es el tenant actual?”, tarde o temprano van a estar en desacuerdo.
Patrón 2: haz que olvidarlo sea imposible
Este es el patrón con más apalancamiento de todo el post. Nunca dependas de que alguien recuerde un where. Empújalo al modelo con un global scope y llénalo al crear:
trait BelongsToTenant
{
protected static function bootBelongsToTenant(): void
{
static::addGlobalScope('tenant', function (Builder $builder) {
$builder->where(
$builder->getModel()->getTable().'.tenant_id',
app(Tenant::class)->id
);
});
static::creating(function (Model $model) {
$model->tenant_id ??= app(Tenant::class)->id;
});
}
}
Ahora un simple Invoice::find($id) es seguro por construcción. Alguien que no sepa nada de tu modelo de tenancy escribe código correcto por defecto.
Dos cosas que vigilar:
withoutGlobalScope()es un olor. Búscalo en cada revisión. Hay usos legítimos (herramientas de admin, reportes cruzados), pero cada uno debe ser deliberado y evidente.- El scope no cubre
DB::table(). Las llamadas crudas al query builder se saltan Eloquent por completo, igual que cualquier subconsulta dentro de unselectRaw. Si tu código se apoya enDB::table(), ahí va a estar la fuga.
Patrón 3: el scope no es autorización
Un global scope responde “¿qué filas existen para este tenant?”. No responde “¿este usuario tiene permiso de tocar esta fila?”.
Son preguntas distintas, y confundirlas produce un bug sutil: un usuario con rol de solo lectura puede mutar lo que quiera dentro de su propio tenant, porque el scope dejó pasar la consulta y nadie revisó la policy.
Conserva las dos capas:
public function __invoke(UpdateInvoiceRequest $request, Invoice $invoice)
{
// El scope ya garantizó que $invoice es del tenant actual.
// La policy decide si *este usuario* puede actualizarla.
$this->authorize('update', $invoice);
// ...
}
Un efecto secundario útil de filtrar primero: un ID de otro tenant regresa 404, no 403. No confirmas que el recurso existe en otro lado, que es justo lo que quieres — la misma lógica anti-enumeración que aplicarías en un endpoint de login.
Patrón 4: todo lo que vive fuera del request olvida el tenant
Aquí es donde se rompen calladamente casi todas las implementaciones. Tu middleware ató el tenant al contenedor durante un request HTTP. Los workers de cola, los comandos agendados y los listeners de broadcast no tienen ese request.
Peor: un worker de larga vida conserva el contenedor del job anterior. Ata el tenant explícitamente, siempre:
class GenerateMonthlyReport implements ShouldQueue
{
public function __construct(public int $tenantId) {}
public function handle(): void
{
$tenant = Tenant::withoutGlobalScopes()->findOrFail($this->tenantId);
app()->instance(Tenant::class, $tenant);
// ... ahora los modelos con scope se comportan bien
}
}
Nunca serialices el modelo completo del tenant en el payload y confíes en él: pasa el ID y vuelve a resolverlo. Y para el trabajo agendado que abarca a todos los tenants, sé explícito con el ciclo:
Tenant::withoutGlobalScopes()->cursor()->each(
fn (Tenant $tenant) => GenerateMonthlyReport::dispatch($tenant->id)
);
Un job por tenant, no un job que itera por dentro. Si el tenant 47 truena, los tenants del 48 al 900 igual reciben su reporte.
Patrón 5: pon namespace a todo lo que no sea una tabla
Las filas son la superficie compartida obvia. Las no obvias causan los peores incidentes:
- Llaves de caché.
cache()->remember('dashboard_stats', ...)le va a servir felizmente los números de un tenant a otro. Prefija con el ID del tenant — o mejor, envuélvelo en un helper para que nadie tenga que acordarse. - Almacenamiento de archivos. Guarda las subidas en
tenants/{id}/...y nunca aceptes un segmento de ruta que venga del cliente. - Índices de búsqueda. Un índice compartido necesita el filtro de tenant horneado en cada consulta, no agregado por quien llama.
- Rate limiters y locks. Un lock llamado
report-generationes un embotellamiento entre tenants esperando a suceder.
Patrón 6: índices únicos compuestos
Que users.email sea único a nivel global está bien en una app de un solo tenant y es activamente incorrecto en una multi-tenant: la misma persona que trabaja con dos de tus clientes no puede registrarse dos veces.
$table->unique(['tenant_id', 'email']);
Lo mismo para slugs, folios de facturas, SKUs y cualquier otro “único” que te pida el product owner. Pregunta “¿único dentro de qué?” todas las veces.
Patrón 7: prueba el aislamiento entre tenants como requisito permanente
El aislamiento es una propiedad de seguridad, y las propiedades de seguridad necesitan pruebas que puedan fallar. Hazlo un caso obligatorio en cada feature test de algo que pertenezca a un tenant:
#[Test]
public function no_puede_leer_datos_de_otro_tenant(): void
{
$other = Invoice::factory()->for(Tenant::factory())->create();
$this->actingAs($this->user)
->get(route('invoices.show', $other))
->assertNotFound();
}
Escríbelo para lecturas y para escrituras. Un GET que da 404 mientras el PUT correspondiente pasa es un bug real que he visto más de una vez, casi siempre porque la ruta de escritura usaba otra búsqueda.
Lo que de verdad se rompe en producción
Ordenado por qué tan seguido lo he visto:
- Un
DB::table()perdido en un reporte o una exportación, saltándose todos los scopes que escribiste. - Un job que corrió con un binding viejo en el contenedor de un worker de larga vida.
- Una llave de caché sin prefijo de tenant, normalmente agregada durante un empujón de rendimiento con fecha encima.
- Un
withoutGlobalScope()que era temporal hace seis meses. - Un índice único global que bloquea un registro legítimo: el inofensivo, y el primero del que te vas a enterar.
Ninguno es una falla de arquitectura. Todos son fallas de disciplina, y por eso los patrones que valen la pena son los que eliminan la necesidad de tener disciplina.
La versión corta
Base de datos compartida hasta que se demuestre lo contrario. Resuelve el tenant una sola vez. Filtra automáticamente para que olvidarlo sea imposible. Mantén la autorización como capa aparte. Vuelve a atar el tenant en cada job. Pon namespace a cachés, archivos y locks. Únicos compuestos. Prueba el aislamiento como probarías la autenticación — porque es exactamente eso.
El multi-tenancy no falla con estruendo. Falla como un cliente viendo los datos de otro cliente, una vez, de una forma de la que te enteras por un ticket de soporte. Constrúyelo para que esa falla requiera esfuerzo, no nada más un olvido.