php elephant sticker
Photo by RealToughCandy.com on Pexels.com
目次

[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_id en creating para 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_id
  • actor_user_id
  • action (p. ej., member.invited, role.changed)
  • target_type / target_id
  • before / after (mínimo necesario)
  • ip / user_agent / trace_id
  • created_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 con aria-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_id en 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
  • Jobs corriendo sin contexto de tenant
    • Solución: pasar tenant_id y restaurar en handle()
  • Restricciones únicas no scoping por tenant
    • Solución: unique(tenant_id, ...)
  • 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_id mediante 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_id en 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

por greeden

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)