ADR-0008: Multi-Tenancy
Accepted Issue TrackerDate: 2026-02-23 | Last Revised: 2026-03-09
Compliance: ISO 27001
Context
The Issue Tracker currently operates as a single-tenant application serving one company. There is a concrete need to serve 3 companies from the same codebase, each with their own subdomain (e.g., company-a.example.com).
One company has explicitly requested hard data isolation — their data must not share a database with other tenants. This rules out logical isolation (tenant_id column scoping) and makes database-per-tenant the required approach.
The team already operates stancl/tenancy in another project and has experience with database-per-tenant architectures. However, operational experience with that package revealed frequent friction — the team regularly fights against its automatic behaviors.
The tenant count is fixed at 3 known companies. Dynamic tenant provisioning is not a requirement.
Decision
DIY Database-per-Tenant
Multi-tenancy is implemented as a thin, custom layer using Laravel's built-in primitives: middleware, database configuration, and service container. No external multi-tenancy package.
How It Works
- Tenant identification —
IdentifyTenantmiddleware resolves the subdomain against a centraldomainstable and switches the database connection - Central database — Contains only tenant and domain records (thin registry)
- Tenant databases — All application data (users, issues, projects, etc.) lives in per-tenant databases
- Connection switching — A
TenantManagerservice reconfigures thetenantdatabase connection at runtime
Core Components (~7 classes)
| Component | Purpose |
|---|---|
Tenant model | Central registry — holds database name and config |
Domain model | Maps subdomains to tenants |
TenantManager | Service — switches database connections |
IdentifyTenant middleware | Resolves subdomain → tenant → database |
TenantAware trait | Serializes tenant context for queued jobs |
| Cache prefix configuration | Prevents cross-tenant cache pollution |
tenant:migrate command | Runs migrations against all tenant databases |
Users Are Tenant-Scoped
Each user account exists in exactly one tenant database. A person working for multiple companies has separate accounts with separate credentials.
Migration Strategy
Existing production data belongs to a single company and becomes Tenant 1. The existing database becomes the first tenant database. New empty databases are created for Tenants 2 and 3.
Model Classification
Every Eloquent model is classified as central or tenant:
| Classification | Models |
|---|---|
| Central | Tenant, Domain |
| Tenant | All application models (User, Issue, Project, Sprint, Epic, Comment, TimeLog, etc.) |
Central models declare protected $connection = 'central'. Tenant models use the default tenant connection.
Architecture Test Enforcement
it('central models use the central connection', function () {
$centralModels = [Tenant::class, Domain::class];
foreach ($centralModels as $model) {
expect((new $model)->getConnectionName())->toBe('central');
}
});
it('tenant models do not use the central connection', function () {
$tenantModels = getAppModels(exclude: [Tenant::class, Domain::class]);
foreach ($tenantModels as $model) {
expect((new $model)->getConnectionName())->not->toBe('central');
}
});Impact on Other Decisions
| Decision | Impact |
|---|---|
| Audit Logging | Benefits — Hash chains are per-tenant by default (separate databases). No cross-tenant contention. Schema unchanged. Seed values per-tenant. |
| Cascade Deletion | Unaffected — operates within a single database context |
| AI Interaction Logging | Tenant-scoped — AI logs live in the tenant database where the interaction occurred |
| Two-Tier Authorization | Unaffected — policies operate on tenant-scoped models |
Edge Cases
| Scenario | Solution |
|---|---|
| Queued jobs | TenantAware trait serializes tenant ID, restores context on deserialization |
| Cache pollution | Cache prefix includes tenant ID, set during connection switching |
| Scheduled tasks | Tenant-iterating artisan commands |
| File storage | Tenant-scoped paths: storage/app/tenants/{id}/ |
| Testing | Test setup initializes tenant context; factory creates test tenant + database |
Planned Evolution: Request-Scoped Tenancy
Status
This evolution is planned but not yet implemented. It refactors the tenancy layer from a mutable singleton to a request-scoped architecture, preparing for Laravel Octane adoption.
Problem
The current TenantManager is registered as a singleton that stores the current tenant as mutable private state and mutates shared config values (database.default, cache.prefix, filesystems.disks.local.root).
This works under PHP-FPM's process-per-request model, but is fundamentally unsafe under Laravel Octane (Swoole/RoadRunner/FrankenPHP), where the application instance persists across requests:
- Tenant state leakage — if a request fails before
reset()runs, the next request inherits the previous tenant's database connection - Config mutation is global —
config()->set(...)changes shared state. Under Octane, concurrent requests fight over these values - No automatic cleanup — the implementation relies on middleware ordering and manual
reset()calls
Proposed Architecture: TenantContext + TenantSwitcher
Split the current TenantManager into two classes with distinct lifecycles:
TenantContext — Request-scoped via $this->app->scoped(). Holds the current tenant. Auto-flushed after each request and after each queued job. This is the same mechanism already used by RequestContext in the codebase.
// app/Tenancy/TenantContext.php
final class TenantContext
{
private ?Tenant $tenant = null;
public function set(Tenant $tenant): void { $this->tenant = $tenant; }
public function current(): ?Tenant { return $this->tenant; }
public function currentId(): ?int { return $this->tenant?->id; }
public function isActive(): bool { return $this->tenant !== null; }
}TenantSwitcher — Stateless singleton. Performs database connection switching, cache prefix configuration, and storage path setup. No mutable state.
// app/Tenancy/TenantSwitcher.php
final readonly class TenantSwitcher
{
public function switchTo(Tenant $tenant): void { /* switch DB, cache, storage */ }
public function reset(): void { /* restore original config */ }
}Middleware with Structural Cleanup
The middleware gains a terminate() method for guaranteed cleanup:
public function handle(Request $request, Closure $next): Response
{
$this->context->set($tenant);
$this->switcher->switchTo($tenant);
return $next($request);
}
public function terminate(Request $request, Response $response): void
{
if ($this->context->isActive()) {
$this->switcher->reset();
}
}Implementation Phases
| Phase | What | Outcome |
|---|---|---|
| 1 | Extract TenantContext (scoped binding) | Read access to current tenant goes through scoped binding |
| 2 | Add terminate() to middleware | Structural cleanup guarantee |
| 3 | Rename TenantManager → TenantSwitcher | Clean separation of concerns |
| 4 | Update TenantAware trait | Queued jobs use same scoped/stateless pattern |
| 5 | Eliminate config mutation | Full Octane readiness — no shared state mutation |
Config Mutation Strategy
Under PHP-FPM (current deployment), config mutation via config()->set() is safe because each request runs in its own process. The scoped binding + terminate() cleanup is sufficient.
When Octane is adopted, config mutation must be replaced with dynamic resolution (tenant-aware DatabaseManager wrapper, per-request config overlay). Phase 5 addresses this.
Files Affected
13 files reference the current TenantManager:
- Core:
TenantManager.php,IdentifyTenant.php,TenantAware.php,AppServiceProvider.php - Consumers:
IssueChannel.php,PrivateAnnouncement.php,ProjectDomainUpdateEvent.php,ProfileResource.php,GithubWebhookController.php,LinkProjectGithubRepoAction.php,HandlePullRequestWebhookAction.php - CLI:
TenantMigrateCommand.php,DevResetCommand.php
Options Considered
| Option | Verdict | Reason |
|---|---|---|
| Logical isolation (tenant_id columns) | Rejected | Client requires hard database isolation. A missed query scope = cross-tenant data leak. |
stancl/tenancy package | Rejected | Uncertain maintenance (v4 announced 2021, still unreleased). Laravel 12 compatibility unconfirmed. Automatic/magical bootstrapping conflicts with explicit-over-implicit principle. Team experience confirms friction. |
spatie/laravel-multitenancy | Considered, not chosen | Actively maintained and well-aligned philosophically, but for 3 fixed tenants the implementation surface is small enough that a package adds dependency without proportionate value. Remains a viable fallback. |
| Separate deployments | Rejected | Triples operational burden for 3 tenants on the same codebase. Not justified. |
| DIY database-per-tenant | Accepted | Full control, small scope (~7 classes), zero external dependency for a core concern. Team gains deep understanding of the plumbing. |
Consequences
Positive
- Hard data isolation satisfies the client's explicit requirement
- Structurally impossible cross-tenant data leaks
- Per-tenant backups and retention policies
- Audit trail naturally tenant-isolated without schema changes
- Zero external dependency — team fully owns the infrastructure
- Aligned with Explicit Over Implicit principle
- Small implementation surface proportionate to 3-tenant scope
- Request-scoped evolution prepares for Octane without breaking current deployment
Negative
- Every migration runs per-tenant (3x currently)
- Developers must be aware of central vs tenant context
- No cross-tenant queries (e.g., "all my tasks across companies")
- Separate user accounts per tenant means no unified identity
- Team owns all maintenance for the tenancy layer
Risks
- Missed tenant context in queued jobs → mitigated by
TenantAwaretrait + architecture tests - Cache key collisions → mitigated by atomic prefix configuration
- Forgotten edge cases in new features → mitigated by documented edge cases + code review checklist
- Config mutation race under Octane → mitigated by Phase 5 of request-scoped evolution