Skip to content

arqel-dev/tenant — Referencia de API

Namespace Arqel\Tenant\. Primitivas de multi-tenancy: un singleton TenantManager, un contrato TenantResolver con cinco implementaciones concretas, middleware de arranque, un trait de Eloquent más un global scope, una regla de unicidad consciente del tenant, adaptadores opcionales para stancl/tenancy y spatie/laravel-multitenancy, cambio de tenant, theming white-label y feature gates.

El paquete cubre de forma nativa el caso de base de datos única con tenant-por-fila, y delega el aislamiento multi-base de datos a soluciones de terceros maduras a través de adaptadores. Ni stancl/tenancy ni spatie/laravel-multitenancy son dependencias duras — cada adaptador se protege con class_exists.

Arqel\Tenant\TenantManager (final, singleton)

El constructor recibe (?TenantResolver $resolver = null, ?Dispatcher $events = null) — una app sin tenancy sigue obteniendo un manager funcional.

MétodoTipoDescripción
resolve(Request $request)?ModelResuelve mediante el resolver configurado, memoizado por petición
set(?Model $tenant)voidDefine el tenant actual; despacha TenantResolved / TenantForgotten
forget()voidLimpia el tenant actual y despacha TenantForgotten
runFor(Model $tenant, Closure $callback)mixedIntercambia y restaura vía try/finally. Requiere un Model — no se acepta null
current()?Model
currentOrFail()Model
hasCurrent()bool
id()int|string|nullClave primaria del tenant actual
identifier()stringValor de la columna identificadora, vía identifierFor() del resolver
resolved()boolSi la resolución ya se ejecutó para esta petición
availableFor(Authenticatable $user)arrayDelega en el resolver; lanza LogicException cuando este no implementa SupportsTenantSwitching
canSwitchTo(Authenticatable $user, Model $tenant)boolLa misma delegación
switchTo(Authenticatable $user, Model $tenant)voidLa misma delegación; despacha TenantSwitched

Contracts

Arqel\Tenant\Contracts\TenantResolver

php
public function resolve(Request $request): ?Model;
public function identifierFor(Model $tenant): string;

Arqel\Tenant\Contracts\SupportsTenantSwitching

php
public function availableFor(Authenticatable $user): array;
public function canSwitchTo(Authenticatable $user, Model $tenant): bool;
public function switchTo(Authenticatable $user, Model $tenant): void;

Resolvers

Arqel\Tenant\Resolvers\AbstractTenantResolver (abstract) implementa ambos contratos. Constructor (string $modelClass, string $identifierColumn = 'id') — lanza InvalidArgumentException cuando $modelClass no es una subclase de Model de Eloquent. identifierFor() lee la columna configurada y recae en getKey().

Cinco resolvers concretos, todos en Arqel\Tenant\Resolvers\ e intencionalmente no final, para que las apps puedan sobrescribir el parseo de host/cabecera:

ClaseFuente de resolución
SubdomainResolverPrimera etiqueta del host
PathResolverUn segmento de la ruta URL
HeaderResolverUna cabecera de la petición
SessionResolverClave de sesión. Sobrescribe switchTo() para persistir el valor de la columna identificadora en la misma clave de sesión que lee su resolve()
AuthUserResolvercurrentTeam al estilo Jetstream sobre el usuario autenticado; acepta availableRelation + foreignKeyColumn

Middleware

Middleware\ResolveTenantMiddleware (final, alias arqel.tenant)

handle(Request $request, Closure $next, string $mode = self::MODE_REQUIRED). Dos constantes: MODE_REQUIRED ('required') y MODE_OPTIONAL ('optional'). El parseo del modo no distingue mayúsculas y tolera espacios; un valor desconocido degrada a required. En modo required, un tenant sin resolver lanza Exceptions\TenantNotFoundException, cuyo render() devuelve un 404 JSON, la vista Inertia arqel::errors.tenant-not-found, o un 404 de Symfony como último recurso.

Middleware\RequireTenantFeature (final, alias arqel.tenant.feature)

handle(Request $request, Closure $next, string $feature) — se usa como 'arqel.tenant.feature:analytics'. Devuelve 404 sin tenant, 500 con un mensaje accionable cuando el modelo de tenant no tiene hasFeature, y 402 con JSON {error: 'feature_not_available', feature, message} cuando la feature está deshabilitada.

Integración con Eloquent

Concerns\BelongsToTenant (trait)

Registra Scopes\TenantScope y rellena automáticamente la clave foránea al crear. La clave se resuelve así: propiedad $tenantForeignKeyconfig('arqel.tenancy.foreign_key')'tenant_id'.

MétodoTipoDescripción
getTenantKeyName() / getQualifiedTenantKeyName()stringClave foránea resuelta
tenant()BelongsTo
scopeWithoutTenant(Builder $query)BuilderElimina el global scope
scopeForTenant(Builder $query, Model|int|string $tenant)BuilderConsulta cross-tenant explícita

Scopes\TenantScope (final, implements Scope)

apply(Builder $builder, Model $model): void. No hace nada, de forma elegante, cuando no hay tenant actual o el manager no está vinculado en el contenedor.

Rules\ScopedUnique (final, implements ValidationRule)

Reemplazo consciente del tenant para la regla unique de Laravel. Constructor:

php
new ScopedUnique(
    table: 'projects',
    column: 'slug',
    ignore: $project->id,        // por defecto null
    ignoreColumn: 'id',          // por defecto 'id'
    tenantForeignKey: null,      // por defecto: resuelto desde la config
    connection: null,
);

Aplica where(<tenant_fk>, <id>) cuando hay un tenant actual, y recae en una verificación de unicidad global en caso contrario. Antes de aplicar el filtro comprueba hasColumn sobre la tabla destino — si la columna de clave foránea del tenant no existe, el filtro se omite en lugar de producir un error "Unknown column". Cualquier fallo al inspeccionar el esquema trata la columna como presente (manteniendo el comportamiento acotado al tenant).

Concerns\HasFeatures (trait)

hasFeature(string): bool, enableFeature(string): void, disableFeature(string): void, getFeatures(): array. Es defensivo ante un atributo features que no sea un array y deduplica; declara $casts = ['features' => 'array'] en el modelo de tenant.

Theming

Theming\TenantTheme (final, value object readonly)

Cinco propiedades nulables: primaryColor, logoUrl, fontFamily, secondaryColor, faviconUrl. La factory TenantTheme::fromTenant(?Model $tenant): self lee los atributos canónicos de forma defensiva. toArray(): array e isEmpty(): bool.

Theming\TenantThemeResolver (final, singleton)

resolve(): TenantTheme — construye el theme a partir de TenantManager::current().

Theming\CssVarsRenderer (final)

CssVarsRenderer::renderInlineStyle(TenantTheme $theme): string emite <style>:root { --color-primary: …; }. Cada slot se valida contra una allowlist según su contexto CSS (los colores aceptan hex / rgb() / hsl() / colores con nombre; font_family acepta letras, dígitos, espacio, coma, guion y comillas simples; las URLs deben ser http(s) o relativas a la raíz y se emiten como un url('…') escapado). Los valores que no pasan la allowlist se omiten — nunca se emiten sin escapar — lo que neutraliza payloads de inyección CSS que contengan }.

Adaptadores

Ambos viven en Arqel\Tenant\Integrations\ e implementan TenantResolver; cada uno está protegido por class_exists, de modo que ninguno de los paquetes de terceros se vuelve una dependencia dura.

ClaseComportamiento
StanclAdapter (final)Lee Stancl\Tenancy\Tenancy::tenant (nombre del binding en la constante TENANCY_BINDING); respeta getTenantKey() con getKey() como fallback
SpatieAdapter (final)Llama al current() estático de Spatie; un modelClass vacío recae en la constante SPATIE_TENANT_CLASS (Spatie\Multitenancy\Models\Tenant)

Eventos

Todos final con propiedades promovidas readonly:

EventoPayload
Events\TenantResolvedModel $tenant
Events\TenantForgottenModel $tenant
Events\TenantSwitched?Model $from, Model $to, Authenticatable $user

HTTP

Registradas bajo web + auth con el prefijo admin/tenants:

VerboRutaNombreAcción
POSTadmin/tenants/{tenantId}/switcharqel.tenant.switchTenantSwitcherController::switch — 404/403, luego dispatch + redirect
GETadmin/tenants/availablearqel.tenant.availableTenantSwitcherController::list{current, available[]}

Comandos de Artisan

Cada scaffolder escribe tres stubs opcionales (controlador + snippet de rutas + página Inertia), es idempotente (omite con exit 0 salvo --force) y añade contenido a routes/web.php exactamente una vez, detrás de un marcador.

ComandoFunción
arqel:tenant:scaffold-registration {--force}Flujo de alta de tenant
arqel:tenant:scaffold-profile {--force}Ajustes del perfil del tenant
arqel:tenant:scaffold-billing {--force}Esqueleto de la página de facturación

Ejemplo

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 (): void {
    Route::get('/admin', AdminController::class);
});
php
use Arqel\Tenant\Concerns\BelongsToTenant;

final class Project extends Model
{
    use BelongsToTenant;

    // protected string $tenantForeignKey = 'organization_id';
}

Project::all();                     // acotado automáticamente al tenant actual
Project::withoutTenant()->get();    // vía de escape explícita
Project::forTenant($otherId)->get();

// Override de admin que preserva los lifecycle hooks / el rastro de auditoría:
app(TenantManager::class)->runFor($otherTenant, fn () => Project::all());
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()],
    ];
}

Relacionado

Licencia MIT — construido con Inertia + React + Laravel.