0002. Multi-tenancy via Single Database with tenant_id Discriminator¶
- Status: Accepted
- Date: 2026-05-27
- Deciders: Zeeshan Ahmed Raja (sole maintainer, v0.1.0)
- Consulted: —
- Informed: Future contributors via this ADR; SRS §3.2.2 (FR-011 through FR-015); arc42 §2.1 TC-13.
Context¶
Skontro is a multi-tenant SaaS-style application that can also be self-hosted. Multi-tenancy is a foundational architectural decision: it shapes the data model, the authentication and authorization layer, the migration strategy, the backup strategy, and the operational footprint.
Three patterns dominate the literature:
- Database-per-tenant — each tenant gets its own database. Strong isolation, large operational overhead (migrations N times, backups N times).
- Schema-per-tenant — each tenant gets its own schema within a single Postgres database. Medium isolation, medium operational overhead.
- Single database with
tenant_iddiscriminator — every tenant-owned row carries atenant_idcolumn; isolation is enforced in application code. Light operational overhead, isolation depends on application correctness.
The decision must support:
- Operational simplicity. One database to back up, one set of migrations to run, one connection pool. Aligns with OC-2 (solo maintainer) and OC-3 (~€15/month budget).
- Reasonable isolation guarantees. Cross-tenant data leakage would be catastrophic both legally (GDPR/DSGVO) and reputationally.
- A path to scale. v0.1 reference deployment supports up to ~50 tenants. Beyond that, sharding strategies may need development — but the decision today should not foreclose them.
Decision¶
Skontro will use single database with a tenant_id discriminator column on every tenant-owned table, enforced by:
-
A
BelongsToTenantEloquent trait applied to every tenant-owned model, which:- Adds a global scope filtering all queries by the authenticated user's tenant
- Automatically sets
tenant_idoncreatingevents to the authenticated user's tenant
-
Service-level guarantees in the authentication layer that bind every authenticated request to exactly one tenant via the Sanctum token's owning user.
-
Direct cross-tenant ID lookups return HTTP 404, not 403. This is a deliberate choice: 403 leaks the existence of the resource (the recipient learns a resource with that ID exists somewhere); 404 does not.
-
An integration test in the test suite for every tenant-owned model, asserting that User A from Tenant 1 cannot read, modify, or delete records of User B from Tenant 2.
-
Per-tenant atomic sequences for customer / invoice / expense numbers (FR-013). Implemented via a
numbering_sequencestable with row-level locks on increment, scoped to(tenant_id, document_type, year).
Consequences¶
Positive¶
- One database. One
pg_dumpto back up, one set of migrations to run, one connection pool to size. Operational cost scales with usage, not with tenant count. - Cross-tenant queries are trivially possible for reporting. Aggregate metrics (e.g., "across all tenants on this instance, how many invoices were issued in March?") are simple SQL — no cross-database UNION ALL gymnastics.
- Tractable for a solo maintainer. No tenant onboarding automation that creates databases. No tenant-aware migration runner.
- Cost-efficient. Hetzner CX22 can comfortably host ~50 tenants in a single Postgres database.
Negative¶
- Isolation depends on application correctness. A bug in the global scope, in a manual query that bypasses Eloquent, or in a raw SQL statement, can leak data across tenants.
- Mitigation: The
BelongsToTenanttrait is the only sanctioned access path. PHPStan level 8 flags raw queries. Code review checklist includes a "did this touch tenant data?" item. CI integration tests cover cross-tenant isolation for every tenant-owned model.
- Mitigation: The
- No per-tenant encryption at rest. All tenants share the same database encryption (which is at the storage layer, not per-row).
- Mitigation: Tenants with stronger isolation needs are documented as needing the self-host option, where they get their own deployment entirely.
- Schema changes affect all tenants simultaneously. A migration that introduces a new column applies to all tenants at once.
- Mitigation: Migrations are backward-compatible by convention (additive changes preferred; destructive changes happen in multi-step migrations).
- Scale ceiling. Beyond ~50 tenants or a particular tenant becoming very large, the single-DB model may need to be sharded.
- Mitigation: The
tenant_iddiscriminator makes future sharding strategies viable — extract a tenant by selecting all rows with thattenant_id, dump them, and restore into a new database. The application code does not need to change if the connection-resolution layer learns to pick a database pertenant_id.
- Mitigation: The
Risks accepted¶
- If a future architectural review concludes that per-tenant isolation needs are stronger than the application-enforced model provides, a migration to schema-per-tenant or database-per-tenant is possible but expensive. Acceptable: for the v0.1 target user (Mittelstand businesses self-hosting or using a hosted instance with reasonable expectations), application-enforced isolation is sufficient.
Alternatives Considered¶
Alternative A — Database-per-tenant¶
- Pro: Strongest possible isolation. Per-tenant backups and restores. Per-tenant scale tuning.
- Con: N times the operational overhead. Migrations must be applied tenant-by-tenant. Tenant onboarding requires automated database provisioning. Cross-tenant reporting is hard. Connection pooling complicated.
- Verdict: Rejected. The operational cost is incompatible with OC-2 (solo maintainer).
Alternative B — Schema-per-tenant within a single Postgres database¶
- Pro: Postgres schemas provide stronger logical separation than the discriminator model. Per-tenant
search_pathenforces isolation at the session level. - Con: Postgres has a soft upper limit on schema count (thousands work, but tooling like
pg_dumpbecomes slow). Migrations must be applied per schema. Search across tenants requires explicit cross-schema queries. - Verdict: Rejected for v0.1. Could be revisited if a tenant emerges with strong-isolation requirements that the discriminator model cannot satisfy.
Alternative C — Hybrid (small tenants pooled, large tenants on their own database)¶
- Pro: The advantages of both other models in principle.
- Con: Requires a tenant-routing layer in the application that can route a request to the right database. Code complexity, error surface area, and operational tooling all expand.
- Verdict: Rejected for v0.1. Worth revisiting if a tenant with strong-isolation needs becomes a real customer.
References¶
- Laravel multi-tenancy patterns —
https://laravel.com/docs/11.x(search: multi-tenancy) - Spatie laravel-multitenancy —
https://github.com/spatie/laravel-multitenancy - SRS §3.2.2 (FR-011 through FR-015) — multi-tenancy functional requirements
- arc42 §2.1 TC-13 — technical constraint
- arc42 §4.4 — solution-strategy reference
Revision History¶
| Version | Date | Author | Change |
|---|---|---|---|
| 1.0 | 2026-05-27 | Zeeshan Ahmed Raja | Initial decision recorded. |