Skip to content

Multi-tenancy

Paquete: arqel-dev/tenant · Tickets: TENANT-001..015

Propósito

arqel-dev/tenant provee primitivas de multi-tenancy para el stack de Arqel cubriendo dos modos principales:

  • Single-DB scoped (default) — todos los tenants comparten el mismo schema; aislamiento vía Eloquent global scope tenant_id. 80% de los casos. Cero overhead operacional.
  • Multi-DB (opt-in) — cada tenant tiene su propia base de datos. Se integra con stancl/tenancy o spatie/laravel-multitenancy vía adapters; no reinventa migrations/seeders aislados.

La elección es no reinventar: el paquete ofrece un singleton TenantManager + el contrato TenantResolver con 5 implementaciones concretas, y delega multi-DB a soluciones ya maduras.

Inicio rápido

php
// config/arqel.php
return [
    'tenancy' => [
        'resolver' => Arqel\Tenant\Resolvers\SubdomainResolver::class,
        'model' => App\Models\Tenant::class,
        'identifier_column' => 'slug',
        'foreign_key' => 'tenant_id',
    ],
];

// routes/web.php
Route::middleware(['web', 'auth', 'arqel.tenant'])->group(function () {
    Route::get('/admin', AdminController::class);
});

Cada modelo con columna tenant_id añade el trait:

php
use Arqel\Tenant\Concerns\BelongsToTenant;

final class Project extends Model
{
    use BelongsToTenant;
}

// Auto-scoped:
Project::all();

Conceptos clave

TenantManager (singleton)

Source of truth en runtime. APIs principales:

  • resolve(Request) — memoiza por request; llama al resolver configurado.
  • set(?Model) / forget() — dispara los eventos TenantResolved / TenantForgotten.
  • runFor(Model, Closure) — swap+restore vía try/finally; usado para jobs y override de admin.
  • current / currentOrFail / hasCurrent / id / identifier.

Contrato TenantResolver

Define cómo descubrir el tenant a partir del Request. Cinco resolvers incluidos:

ResolverEstrategia
SubdomainResolveracme.app.com → tenant acme
PathResolverapp.com/acme/...
HeaderResolverX-Tenant: acme (APIs)
SessionResolverelección persistida en sesión
AuthUserResolvercurrentTeam estilo Jetstream

Los resolvers en src/Resolvers/ son intencionalmente class (no-final): las apps personalizan el parsing del host, regex de subdominio, o cambian currentTeam por currentOrganization.

Integración Eloquent

  • Trait BelongsToTenant — registra el TenantScope global + auto-rellena tenant_id en creating. La foreign key se resuelve por: $tenantForeignKey en el modelo → config('arqel.tenancy.foreign_key')'tenant_id'.
  • withoutTenant() / forTenant($id) — escapes explícitos.
  • Rules\ScopedUnique — sustituto tenant-aware para la regla unique de Laravel; aplica where(<tenant_fk>, <id>) cuando hay tenant actual. Hace fallback a un check global-unique cuando no hay tenant, o cuando la columna FK del tenant no existe en la tabla destino (guard vía hasColumn, de modo que una tabla mal configurada degrada con gracia en lugar de lanzar "Unknown column").

Adapters multi-DB

Sin hard dep — gated vía class_exists:

  • Integrations\StanclAdapter — lee Stancl\Tenancy\Tenancy::tenant; honra getTenantKey() con fallback a getKey().
  • Integrations\SpatieAdapter — llama al current() estático de Spatie; modelClass vacío hace fallback a Spatie\Multitenancy\Models\Tenant.

Cambio de tenant

Endpoint incluido:

  • POST /admin/tenants/{tenantId}/switchTenantSwitcherController llama a canSwitchToswitchTo → dispara TenantSwitched.
  • GET /admin/tenants/available — devuelve {current, available[]}.

Los resolvers ganan el contrato SupportsTenantSwitching (availableFor / canSwitchTo / switchTo).

SessionResolver::switchTo() persiste el tenant activo en la misma session key que su resolve() vuelve a leer, guardando el valor de la identifier-column (identifierFor()), no la primary key — así un switch sobrevive a la navegación incluso cuando identifier_column es una columna no-PK como slug.

Theming

php
use Arqel\Tenant\Theming\TenantThemeResolver;

public function share(Request $request): array
{
    $theme = app(TenantThemeResolver::class)->resolve();

    return [
        ...parent::share($request),
        'tenant' => [
            'theme' => $theme->isEmpty() ? null : $theme->toArray(),
        ],
    ];
}

CssVarsRenderer::renderInlineStyle() valida cada slot del tema contra una allowlist estricta de su contexto CSS — colores (hex / rgb()/hsl() / color con nombre), font_family (nombres de familia + keywords genéricas) y URLs (http(s) o ruta root-relative, emitida como url('…') escapada). Un valor que no encaja con la allowlist se omite (la custom property no se renderiza), de modo que un payload de CSS injection con } (que intentaría cerrar la regla :root) simplemente desaparece. El descarte de <, >, " se mantiene como defensa en profundidad. Nunca concatenes atributos del tenant directamente en CSS/HTML — siempre pásalos por renderInlineStyle().

Ejemplos

Query cross-tenant (override de admin)

php
app(TenantManager::class)->runFor($otherTenant, fn () => Project::all());

Job hidratado

php
public function handle(): void
{
    app(TenantManager::class)->runFor($this->tenant, function () {
        // Everything here is scoped to the right tenant, even on the queue worker.
        Order::pending()->each->process();
    });
}

Feature gate

php
Route::middleware('arqel.tenant.feature:analytics')->group(function () {
    Route::get('/analytics', AnalyticsController::class);
});

Un tenant sin analytics en el array features → 402 {error: 'feature_not_available', feature, message}.

Anti-patrones

  • Setear current directamente vía el singleton en userland — usa la cadena middleware/resolver.
  • Trait BelongsToTenant sin tenant_id en la migration — el global scope rompe where.
  • Bypassear TenantScope con withoutGlobalScope en el controlador — usa TenantManager::runFor(null, fn () => ...) para preservar auditoría.
  • Renderizar CSS vars de theme sin CssVarsRenderer.

Checklist de leakage cross-tenant

  • [ ] Cada modelo con tenant_id usa BelongsToTenant.
  • [ ] Las migrations declaran tenant_id con FK + índice compuesto donde tenga sentido.
  • [ ] Validación unique reemplazada por ScopedUnique cuando la constraint es por tenant.
  • [ ] Background jobs hidratados vía runFor($job->tenant, ...).
  • [ ] Endpoints del switcher llaman a canSwitchTo antes de switchTo.
  • [ ] CSS vars del theme siempre pasan por CssVarsRenderer::renderInlineStyle().

Relacionado

Licencia MIT — construido con Inertia + React + Laravel.