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/tenancyospatie/laravel-multitenancyví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
// 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:
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 eventosTenantResolved/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:
| Resolver | Estrategia |
|---|---|
SubdomainResolver | acme.app.com → tenant acme |
PathResolver | app.com/acme/... |
HeaderResolver | X-Tenant: acme (APIs) |
SessionResolver | elección persistida en sesión |
AuthUserResolver | currentTeam 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 elTenantScopeglobal + auto-rellenatenant_idencreating. La foreign key se resuelve por:$tenantForeignKeyen el modelo →config('arqel.tenancy.foreign_key')→'tenant_id'. withoutTenant()/forTenant($id)— escapes explícitos.Rules\ScopedUnique— sustituto tenant-aware para la reglauniquede Laravel; aplicawhere(<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íahasColumn, 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— leeStancl\Tenancy\Tenancy::tenant; honragetTenantKey()con fallback agetKey().Integrations\SpatieAdapter— llama alcurrent()estático de Spatie;modelClassvacío hace fallback aSpatie\Multitenancy\Models\Tenant.
Cambio de tenant
Endpoint incluido:
POST /admin/tenants/{tenantId}/switch—TenantSwitcherControllerllama acanSwitchTo→switchTo→ disparaTenantSwitched.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
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)
app(TenantManager::class)->runFor($otherTenant, fn () => Project::all());Job hidratado
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
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
currentdirectamente vía el singleton en userland — usa la cadena middleware/resolver. - ❌ Trait
BelongsToTenantsintenant_iden la migration — el global scope rompewhere. - ❌ Bypassear
TenantScopeconwithoutGlobalScopeen el controlador — usaTenantManager::runFor(null, fn () => ...)para preservar auditoría. - ❌ Renderizar CSS vars de theme sin
CssVarsRenderer.
Checklist de leakage cross-tenant
- [ ] Cada modelo con
tenant_idusaBelongsToTenant. - [ ] Las migrations declaran
tenant_idcon FK + índice compuesto donde tenga sentido. - [ ] Validación
uniquereemplazada porScopedUniquecuando la constraint es por tenant. - [ ] Background jobs hidratados vía
runFor($job->tenant, ...). - [ ] Endpoints del switcher llaman a
canSwitchToantes deswitchTo. - [ ] CSS vars del theme siempre pasan por
CssVarsRenderer::renderInlineStyle().
Relacionado
packages/tenant/SKILL.md— fuente canónicaPLANNING/09-fase-2-essenciais.md§TENANT-001..015stancl/tenancy,spatie/laravel-multitenancy