Multi-tenancy
Package:
arqel-dev/tenant· Tickets: TENANT-001..015
Purpose
arqel-dev/tenant provides multi-tenancy primitives for the Arqel stack covering two main modes:
- Single-DB scoped (default) — all tenants share the same schema; isolation via Eloquent global scope
tenant_id. 80% of cases. Zero operational overhead. - Multi-DB (opt-in) — each tenant has its own database. Integrates with
stancl/tenancyorspatie/laravel-multitenancyvia adapters; doesn't reinvent isolated migrations/seeders.
The choice is don't reinvent: the package offers a TenantManager singleton + TenantResolver contract with 5 concrete implementations, and delegates multi-DB to already-mature solutions.
Quick start
// 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);
});Each model with a tenant_id column adds the trait:
use Arqel\Tenant\Concerns\BelongsToTenant;
final class Project extends Model
{
use BelongsToTenant;
}
// Auto-scoped:
Project::all();Key concepts
TenantManager (singleton)
Runtime source of truth. Main APIs:
resolve(Request)— memoizes per-request; calls the configured resolver.set(?Model)/forget()— dispatchesTenantResolved/TenantForgottenevents.runFor(Model, Closure)— swap+restore via try/finally; used for jobs and admin override.current/currentOrFail/hasCurrent/id/identifier.
TenantResolver contract
Defines how to discover the tenant from the Request. Five resolvers shipped:
| Resolver | Strategy |
|---|---|
SubdomainResolver | acme.app.com → tenant acme |
PathResolver | app.com/acme/... |
HeaderResolver | X-Tenant: acme (APIs) |
SessionResolver | choice persisted in session |
AuthUserResolver | Jetstream-style currentTeam |
Resolvers in src/Resolvers/ are intentionally class (non-final): apps customize host parsing, subdomain regex, or swap currentTeam for currentOrganization.
Eloquent integration
BelongsToTenanttrait — registers the globalTenantScope+ auto-fillstenant_idoncreating. Foreign key resolves by:$tenantForeignKeyon the model →config('arqel.tenancy.foreign_key')→'tenant_id'.withoutTenant()/forTenant($id)— explicit escapes.Rules\ScopedUnique— tenant-aware substitute for Laravel'suniquerule; applieswhere(<tenant_fk>, <id>)when there is a current tenant.
Multi-DB adapters
No hard dep — gated via class_exists:
Integrations\StanclAdapter— readsStancl\Tenancy\Tenancy::tenant; honorsgetTenantKey()withgetKey()fallback.Integrations\SpatieAdapter— calls Spatie's staticcurrent(); emptymodelClassfalls back toSpatie\Multitenancy\Models\Tenant.
Tenant switching
Endpoint shipped:
POST /admin/tenants/{tenantId}/switch—TenantSwitcherControllercallscanSwitchTo→switchTo→ dispatchesTenantSwitched.GET /admin/tenants/available— returns{current, available[]}.
Resolvers gain the SupportsTenantSwitching contract (availableFor / canSwitchTo / switchTo).
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() performs defensive sanitization (drops <, >, " + htmlspecialchars) — never concatenate tenant attributes directly into HTML.
Examples
Cross-tenant query (admin override)
app(TenantManager::class)->runFor($otherTenant, fn () => Project::all());Hydrated job
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);
});A tenant without analytics in the features array → 402 {error: 'feature_not_available', feature, message}.
Anti-patterns
- ❌ Setting
currentdirectly via the singleton in userland — use the middleware/resolver chain. - ❌
BelongsToTenanttrait withouttenant_idin the migration — the global scope breakswhere. - ❌ Bypassing
TenantScopewithwithoutGlobalScopein the controller — useTenantManager::runFor(null, fn () => ...)to preserve auditing. - ❌ Rendering theme CSS vars without
CssVarsRenderer.
Cross-tenant leakage checklist
- [ ] Every model with
tenant_idusesBelongsToTenant. - [ ] Migrations declare
tenant_idwith FK + composite index where it makes sense. - [ ] Validation
uniquereplaced withScopedUniquewhen the constraint is per-tenant. - [ ] Background jobs hydrated via
runFor($job->tenant, ...). - [ ] Switcher endpoints call
canSwitchTobeforeswitchTo. - [ ] Theme CSS vars always go through
CssVarsRenderer::renderInlineStyle().
Related
packages/tenant/SKILL.md— canonical sourcePLANNING/09-fase-2-essenciais.md§TENANT-001..015stancl/tenancy,spatie/laravel-multitenancy