Skip to content

ADR-0008: Multi-Tenancy ​

Accepted Kendo

Date: 2026-02-23 | Last Revised: 2026-03-09

Compliance: ISO 27001

Context ​

The Kendo 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 ​

  1. Tenant identification — IdentifyTenant middleware resolves the subdomain against a central domains table and switches the database connection
  2. Central database — Contains only tenant and domain records (thin registry)
  3. Tenant databases — All application data (users, issues, projects, etc.) lives in per-tenant databases
  4. Connection switching — A TenantManager service reconfigures the tenant database connection at runtime

Core Components (~7 classes) ​

ComponentPurpose
Tenant modelCentral registry — holds database name and config
Domain modelMaps subdomains to tenants
TenantManagerService — switches database connections
IdentifyTenant middlewareResolves subdomain → tenant → database
TenantAware traitSerializes tenant context for queued jobs
Cache prefix configurationPrevents cross-tenant cache pollution
tenant:migrate commandRuns 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:

ClassificationModels
CentralTenant, Domain
TenantAll 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 ​

php
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 ​

DecisionImpact
Audit LoggingBenefits — Hash chains are per-tenant by default (separate databases). No cross-tenant contention. Schema unchanged. Seed values per-tenant.
Cascade DeletionUnaffected — operates within a single database context
AI Interaction LoggingTenant-scoped — AI logs live in the tenant database where the interaction occurred
Two-Tier AuthorizationUnaffected — policies operate on tenant-scoped models

Edge Cases ​

ScenarioSolution
Queued jobsTenantAware trait serializes tenant ID, restores context on deserialization
Cache pollutionCache prefix includes tenant ID, set during connection switching
Scheduled tasksTenant-iterating artisan commands
File storageTenant-scoped paths: storage/app/tenants/{id}/
TestingTest 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:

  1. Tenant state leakage — if a request fails before reset() runs, the next request inherits the previous tenant's database connection
  2. Config mutation is global — config()->set(...) changes shared state. Under Octane, concurrent requests fight over these values
  3. 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.

php
// 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.

php
// 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:

php
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 ​

PhaseWhatOutcome
1Extract TenantContext (scoped binding)Read access to current tenant goes through scoped binding
2Add terminate() to middlewareStructural cleanup guarantee
3Rename TenantManager → TenantSwitcherClean separation of concerns
4Update TenantAware traitQueued jobs use same scoped/stateless pattern
5Eliminate config mutationFull 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 ​

OptionVerdictReason
Logical isolation (tenant_id columns)RejectedClient requires hard database isolation. A missed query scope = cross-tenant data leak.
stancl/tenancy packageRejectedUncertain 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-multitenancyConsidered, not chosenActively 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 deploymentsRejectedTriples operational burden for 3 tenants on the same codebase. Not justified.
DIY database-per-tenantAcceptedFull 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 TenantAware trait + 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

Implementation ​

TerritoryStateNotes
kendoPartialDIY database-per-tenant implemented for 3 fixed tenants. Planned evolution: request-scoped tenancy for Octane readiness (not started).

Architecture documentation for contributors and collaborators.