[Guía completa lista para el terreno] Diseñar un SaaS multi-tenant en Laravel — Aislamiento de tenants (BD/Esquema/Fila), estrategia de Dominio/URL, Facturación y Autorización, Auditoría, Rendimiento y una UI de administración accesible
Lo que aprenderás (puntos clave)
- Enfoques multi-tenant (bases de datos separadas / esquemas separados / aislamiento por filas) y cómo elegir + estrategia de migración
- Resolución del tenant (subdominio / dominio personalizado / prefijo de URL) e implementación con middleware
- Proteger los límites del tenant “por estructura”: global scopes, policies, almacenamiento, caché, colas
- Fundamentos de facturación (planes / asientos / uso), autorización (roles/RBAC), invitaciones, bases de gestión de organizaciones
- Logs de auditoría, retención de datos, eliminación/cancelación, backup/restore, respuesta a incidentes
- Optimización de rendimiento (índices, agregados materializados, separación de jobs)
- Accesibilidad en pantallas de administración (operación por teclado, tablas/formularios, estado sin depender del color, lectores de pantalla, flujos de error)
Lectores previstos (¿a quién le beneficia?)
- Ingenieros Laravel principiante–intermedio: convertir una app single-tenant en un SaaS que gestione de forma segura múltiples organizaciones
- Líderes técnicos / CTO: equilibrar fuerza de separación vs. coste operativo y fijar una arquitectura estándar
- PM / CS / Legal / Seguridad: diseñar teniendo en cuenta la realidad operativa (facturación, permisos, auditoría, eliminación)
- Diseñadores / QA / Accesibilidad: crear una UI de administración de organización que “toda persona pueda usar”
Nivel de accesibilidad: ★★★★★
Esta guía concreta diseño, copy e implementación para cambio de organización, invitaciones, ajustes de permisos, errores de facturación, ordenación de tablas, notificaciones, actualizaciones en vivo e indicadores de estado que no dependan solo del color.
1. Introducción: el riesgo más aterrador en multi-tenancy es la “fuga de límites”
Un SaaS multi-tenant usa una sola aplicación para servir a múltiples organizaciones (tenants). El mayor riesgo es que una persona del Tenant A vea datos del Tenant B. Esto puede ocurrir por bugs o por errores operativos. Por eso tu límite debe protegerse no con “cuidado humano”, sino con estructura.
Esta guía reúne un patrón práctico y listo para producción en Laravel—desde elegir un enfoque hasta middleware, aislamiento de datos, facturación y roles, auditoría, rendimiento y accesibilidad—de extremo a extremo.
2. Modelos de aislamiento multi-tenant: BD separada / esquema separado / aislamiento por filas
2.1 Comparación rápida
-
BD separada (una base de datos por tenant)
- Fuerte: el límite más fuerte, fácil backup/eliminación, potente para requisitos legales
- Débil: crece el número de BD → operaciones más pesadas (conexiones, migraciones, monitorización)
- Mejor para: enterprise, requisitos estrictos de aislamiento, conteo pequeño–medio de tenants
-
Esquema separado (esquemas dentro de una BD)
- Fuerte: más ligero que BD separada, sigue siendo un límite sólido
- Débil: depende del proveedor de BD; la complejidad operativa permanece
- Mejor para: entornos que manejan bien esquemas (p. ej., PostgreSQL)
-
Aislamiento por filas (tablas únicas con
tenant_id)- Fuerte: operaciones más ligeras, escala bien (un solo set de app)
- Débil: errores causan fuga directamente; el indexado es crucial
- Mejor para: startups / SaaS SMB, gran número de tenants
2.2 Conclusión práctica (común en proyectos reales)
- Empieza con aislamiento por filas y protege límites estructuralmente (scope/middleware/tests).
- Si aparece después un cliente enterprise estricto, añade BD separada para él como arquitectura de “dos plantas”—a menudo realista.
3. Resolución del tenant: determinar a qué organización pertenece la request
3.1 Patrones comunes
- Subdominio:
acme.example.com - Dominio personalizado:
app.acme.co.jp(requiere configuración DNS) - Prefijo de URL:
example.com/t/acme(lo más simple; explícito para compartir/SEO)
Los subdominios son el default más común en SaaS. Los dominios personalizados suelen ser requisito enterprise—por eso conviene diseñar para poder añadirlos más adelante.
3.2 Resolver tenant en middleware
// app/Http/Middleware/ResolveTenant.php
namespace App\Http\Middleware;
use Closure;
use App\Models\Tenant;
class ResolveTenant
{
public function handle($request, Closure $next)
{
// Ejemplo: parsear desde subdominio
$host = $request->getHost(); // acme.example.com
$slug = explode('.', $host)[0]; // acme
$tenant = Tenant::where('slug', $slug)->first();
abort_if(!$tenant, 404);
app()->instance('tenant', $tenant);
return $next($request);
}
}
// helper
function tenant(): \App\Models\Tenant {
return app('tenant');
}
3.3 Aplicarlo a rutas
Route::middleware(['resolve.tenant', 'auth'])->group(function () {
Route::get('/dashboard', DashboardController::class)->name('dashboard');
});
4. Forzar límites: usar global scopes para prevenir fugas “estructuralmente”
Con aislamiento por filas, cada tabla incluye tenant_id, y cada query debe incluir esa condición. Hacerlo manualmente es peligroso, así que fuérzalo con global scopes de Laravel.
// app/Models/Concerns/BelongsToTenant.php
namespace App\Models\Concerns;
use Illuminate\Database\Eloquent\Builder;
trait BelongsToTenant
{
protected static function bootBelongsToTenant()
{
static::addGlobalScope('tenant', function (Builder $builder) {
if (app()->bound('tenant')) {
$builder->where($builder->getModel()->getTable().'.tenant_id', tenant()->id);
}
});
static::creating(function ($model) {
if (app()->bound('tenant') && empty($model->tenant_id)) {
$model->tenant_id = tenant()->id;
}
});
}
}
class Project extends Model {
use \App\Models\Concerns\BelongsToTenant;
protected $fillable = ['name','tenant_id'];
}
Puntos clave
- Auto-asignar
tenant_idencreatingpara evitar escrituras “sin tenant_id”. - Para pantallas de operador/admin que cruzan tenants, usar
withoutGlobalScope('tenant')con cuidado. - Aún más seguro: colocar operaciones cross-tenant en una app separada o conexión separada.
5. Autorización: doble guardia con límite de tenant + RBAC
5.1 Ejemplo de diseño de roles
- Owner (incluye contrato/facturación/eliminación)
- Admin (configuración/gestión de miembros)
- Member (operaciones normales)
- Viewer (solo lectura)
5.2 Ejemplo de Policy (match de tenant + rol)
public function update(User $user, Project $project): bool
{
if ($project->tenant_id !== $user->tenant_id) return false;
return $user->hasRole('admin') || $user->hasRole('owner');
}
- “Scope” + “autorización” como dos capas es fuerte.
- En onboarding por links de invitación, haz el tenant explícito para prevenir uniones erróneas y confusiones.
6. Facturación: un reparto realista de planes, asientos y uso
6.1 Lo básico
- Plan (mensual/anual): límites de funcionalidades (proyectos, almacenamiento)
- Asientos (seats): número de usuarios
- Uso: llamadas API / almacenamiento / volumen de mensajes
No lo hagas todo a la vez. Un comienzo realista es:
- Plan + asientos (o solo plan)
6.2 Errores comunes en medición de uso
- Evitar doble conteo por reintentos/duplicados (claves de idempotencia)
- No agregar en tiempo real—materializar con jobs programados
- Mantener logs auditables (p. ej.,
usage_events) para trazabilidad
7. Almacenamiento, caché, sesiones: separar por tenant
7.1 Namespacing de rutas en storage
- Usar
tenants/{tenant_id}/...para prevenir lecturas cruzadas accidentales - Preferir URLs firmadas; evitar colocación pública directa
$path = "tenants/".tenant()->id."/uploads/".$filename;
Storage::disk('s3')->put($path, $content);
7.2 Namespacing de claves de caché
$key = "t:".tenant()->id.":projects:all";
Cache::remember($key, 300, fn()=> Project::orderBy('id')->get());
- La caché se filtra fácilmente—incluye siempre el tenant ID en las claves.
7.3 Propagar tenant en jobs (colas)
Pasa tenant_id al job y restaura el contexto del tenant durante el procesamiento.
class RecalcUsage implements ShouldQueue
{
public function __construct(public int $tenantId) {}
public function handle()
{
app()->instance('tenant', Tenant::findOrFail($this->tenantId));
// tenant() ya funciona
}
}
8. Eliminación y retención: diseña la cancelación dentro del sistema
- Soft delete (ventana recuperable) → hard delete (permanente)
- Los backups también deben respetar la política de eliminación (límites de retención)
- Confirma primero requisitos legales/contractuales (p. ej., retención de facturas)
8.1 Ejemplo de flujo de cancelación
- Solo Owner
- Confirmación en dos pasos (re-escribir nombre de la org)
- Explicar alcance (qué se elimina vs. qué datos de facturación se retienen)
- Ruta de soporte post-completado
Nota de accesibilidad: no depender solo del color para advertencias—usar encabezados y listas con viñetas.
9. Logging de auditoría: registrar “quién hizo qué” por tenant
9.1 Campos base de auditoría
tenant_idactor_user_idaction(p. ej.,member.invited,role.changed)target_type/target_idbefore/after(mínimo necesario)ip/user_agent/trace_idcreated_at
AuditLog::create([
'tenant_id' => tenant()->id,
'actor_user_id' => auth()->id(),
'action' => 'member.invited',
'target_type' => 'user',
'target_id' => $invitee->id,
'meta' => ['email_masked' => mask_email($invitee->email)],
'trace_id' => request()->header('X-Trace-Id'),
]);
10. Rendimiento: eliminar cuellos de botella del aislamiento por filas
10.1 Reglas generales de indexado
- Para la mayoría de tablas, considerar
(tenant_id, id)o(tenant_id, created_at) - Para filtros frecuentes:
(tenant_id, status, created_at)etc. - Restricciones únicas deben ser por tenant:
unique(tenant_id, slug)
10.2 Materializar agregados
Los agregados del dashboard se vuelven pesados si se calculan on-demand. Preferir:
- agregados programados en
daily_usage,tenant_stats, etc. - la UI lee solo esas tablas
Esto se mantiene estable cuando crece el número de tenants.
10.3 Archivado
Logs antiguos de auditoría/eventos se pueden mover, según requisitos, a:
- tablas separadas
- BD separada
- object storage
para proteger el rendimiento de las hot tables.
11. UX de administración: prevenir “accidentes” en cambio de org, invitaciones y permisos
11.1 Selector de tenant (UI de cambio de org)
- Mostrar siempre el nombre de la org actual
- Después de cambiar, mover el foco al título de la página
- Mostrar nombre + descripción, no solo IDs
<nav aria-label="Cambiar organización">
<p>Organización actual: {{ tenant()->name }}</p>
<ul>
@foreach($memberships as $m)
<li>
<a href="{{ $m->tenant->url }}" aria-current="{{ $m->tenant_id===tenant()->id ? 'true':'false' }}">
{{ $m->tenant->name }}
</a>
</li>
@endforeach
</ul>
</nav>
11.2 Flujo de invitación accesible
- Resumir errores con
role="alert", conectar campos conaria-describedby - Enviar emails de invitación con alternativa en texto plano; usar texto de enlace específico
- Para invitaciones expiradas, evitar 419/403 genéricos—proveer una página dedicada de explicación
12. Probar límites de tenant: “detener fugas en CI”
Con aislamiento por filas, la pesadilla es “una pantalla olvidó el filtro del tenant”. La mejor defensa son tests que reproduzcan la fuga y fallen.
12.1 Feature test (los datos cross-tenant no deben aparecer)
public function test_tenant_isolation()
{
$t1 = Tenant::factory()->create();
$t2 = Tenant::factory()->create();
$u1 = User::factory()->create(['tenant_id'=>$t1->id]);
$p2 = Project::factory()->create(['tenant_id'=>$t2->id]);
$this->actingAs($u1);
app()->instance('tenant', $t1);
$res = $this->get('/projects');
$res->assertOk();
$res->assertDontSee($p2->name);
}
12.2 Tests de auditoría
- Asegurar que acciones importantes (cambio de rol, invitación, cambios de facturación) siempre creen logs de auditoría.
13. Respuesta a incidentes: un runbook mínimo para fugas de límites
- Identificar alcance (qué tenants / ventana de tiempo / funcionalidad)
- Buscar en logs usando
trace_id,tenant_id,user_id - Contención temporal (feature flag off, endurecer permisos, maintenance page)
- Corregir + prevenir recurrencia (añadir tests, forzar scopes, ajustar pasos de revisión)
- Comunicación a usuarios (qué ocurrió, impacto, prevención, contacto de soporte)
Para SaaS, “explicar y arreglar rápido” suele construir más confianza que “ocultarlo”.
14. Errores comunes y cómo evitarlos
- Olvidar
tenant_iden una query- Solución: global scope + auto-asignación en create; operaciones cross-tenant en app separada
- Claves de caché sin tenant
- Solución: forzar
t:{tenant_id}en convenciones de nombres
- Solución: forzar
- Jobs corriendo sin contexto de tenant
- Solución: pasar
tenant_idy restaurar enhandle()
- Solución: pasar
- Restricciones únicas no scoping por tenant
- Solución:
unique(tenant_id, ...)
- Solución:
- Desajuste de facturación entre UI y realidad
- Solución: agregados materializados + logs de auditoría; eventos idempotentes de cambio
- UI confusa al cambiar de org
- Solución: mostrar siempre la org actual; enfocar heading tras el cambio
- No diseñar cancelación/eliminación
- Solución: decidir primero políticas de retención/eliminación/backup
15. Checklist (compartible)
Aislamiento / límites
- [ ] Elegir modelo de aislamiento (fila/esquema/BD) + estrategia de migración
- [ ] Middleware de resolución de tenant (subdominio/dominio personalizado/URL)
- [ ] Forzar
tenant_idmediante global scopes - [ ] Doble guardia con Policy (match de tenant + roles)
Datos y sistemas alrededor
- [ ] Rutas de storage namespaced + URLs firmadas
- [ ] Claves de caché incluyen tenant ID
- [ ] Propagación de
tenant_iden jobs - [ ] Restricciones únicas e índices incluyen
tenant_id
Operaciones
- [ ] Logs de auditoría para acciones críticas
- [ ] Política de cancelación/eliminación/retención/backup
- [ ] Runbook de incidentes
Facturación / permisos
- [ ] Definir alcance de plan/asientos/uso
- [ ] Flujo de invitación + expiración/invalidación
- [ ] RBAC (Owner/Admin/Member/Viewer)
Accesibilidad
- [ ] Claridad en cambio de org +
aria-current - [ ] Errores de formulario con
role="alert"+aria-describedby - [ ] Indicadores de estado no dependientes solo del color
- [ ] Tablas/listas amigables para teclado + estructura de encabezados
Testing
- [ ] Tests que eviten lecturas cross-tenant
- [ ] Tests que aseguren creación de logs de auditoría
- [ ] Tests para separación de tenant en caché/jobs
16. Resumen
En un SaaS multi-tenant, prevenir fugas de límites entre tenants es lo central. En Laravel, puedes proteger límites “por estructura” usando middleware de resolución de tenant y global scopes, y luego construir encima RBAC, facturación, logs de auditoría, eliminación/retención y optimización de rendimiento. Y cuanto más diseñes cambio de organización, invitaciones y pantallas de permisos con accesibilidad en mente, menos accidentes operativos tendrás. Diseña con calma, sella fugas con tests y haz crecer un SaaS en el que la gente pueda confiar a largo plazo.
Enlaces de referencia
-
Documentación oficial de Laravel
-
Paquetes comunes de multi-tenancy
-
Seguridad / operaciones
-
Accesibilidad
