Skip to content

ADR-0008: Multi-Tenancy

Accepted Issue Tracker

Date: 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

  1. Tenant identificationIdentifyTenant 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 globalconfig()->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 TenantManagerTenantSwitcherClean 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

Architecture documentation for contributors and collaborators.