Decision Log — Appendices
The companion appendices of the retired single-file
docs/tenancy/decision log, preserved verbatim (original section numbering kept): open items, the tenancy package inventory, the historical file manifest, the team conventions, and the changelog. Index: decision log.
3. Open items / follow-ups (non-blocking)
- Client (tenant-user) auth — DONE: Sanctum SPA + tenant-scoped
ClientUser(D15). -
userssplit — DONE: central operators inusers; tenant client users in the RLS-scopedclient_users. - Client account flows: password reset + email verification for
ClientUser(a separate broker/notifications from the central operator flows). - Per-environment SPA config: set
SANCTUM_STATEFUL_DOMAINS,SESSION_DOMAIN, andCORS_ALLOWED_ORIGINSto the real client app origin(s) in staging/prod. - Tenant-discovery hardening (D16): Turnstile (D18) + strict rate-limiting now guard it, but it still discloses email→tenant membership; for full anti-enumeration consider an emailed confirmation / magic link + edge (WAF) limiting.
- API docs (Scramble): scope
Scramble::routes()to/api/v1/*, document theX-Tenantheader +cf-turnstile-response, and expose the current version. - spatie/permission: central RBAC for operators now; if tenant users need per-tenant roles, enable spatie teams (team_id = tenant_id) rather than RLS on permission tables.
- notification_log_items: consider adding
tenant_id(auto-RLS) if logs will carry tenant PII — currently central. - Provider (banking-rails) integration: per-tenant Saloon credentials resolved from
tenant()at request time; inbound webhooks received centrally then processed viatenancy()->run($tenant, …). - Pin a stable
stancl/tenancyv4 tag once published (currentlydev-master). - Move the shared UI kit (
resources/views/layouts, genericcomponents,partials,flux) into Foundation (foundation::) if/when the reference churn is worth it (D20).
4. Package inventory (tenancy compatibility)
Legend for Interaction: none · config (config only) · code (needs code when used) · critical (core to tenancy). Now = needed for this foundation; Later = when the relevant feature is built.
| Package | Role | Runs in | Interaction | Tables → scoping | Action |
|---|---|---|---|---|---|
stancl/tenancy dev-master | Tenancy engine (RLS) | both | critical | tenants → central | Done. Configured for single-DB RLS; domains/impersonation unused. |
internachi/modular ^3.0 | Modular monolith | both | config | — | Done. Module migrations run via migrate; tenants:rls auto-policies any module table with a tenant_id FK (proven by ExampleItemRlsTest). Run tenants:rls after migrations (already wired). |
laravel/fortify ^1.37 | Auth backend (login/2FA/passkeys) | central | config | users,password_reset_tokens → central; passkeys → central | Fortify stays central (backoffice operators). Do not enable FortifyRouteBootstrapper. Client (tenant) auth is separate — see the Sanctum row + ClientUser (D15). |
laravel/sanctum ^4.3 | Client SPA auth (cookie/session) | tenant | critical | personal_access_tokens → central (SPA uses sessions) | Done (D15). SPA stateful auth for the tenant API via the client guard + tenant-scoped ClientUser; sanctum.guard=['client'], scope_sessions=false. Add tenant_id to personal_access_tokens only if client API tokens are later issued. |
| laravel/passkeys (via Fortify) | WebAuthn passkeys | central | none (Later) | passkeys → central (tied to operator users) | None now. If client users get passkeys, that table follows the client-users (tenant) decision. |
laravel/horizon ^5 | Queue workers/dashboard (Valkey) | both | critical | — (Redis-backed) | Now: QueueTenancyBootstrapper enabled (✓) — it serializes tenant context into jobs and re-initializes per job, resetting the RLS session var between jobs (no leak on reused connections). Dashboard = central, gate viewHorizon. Later: tenant job tags. |
laravel/pennant ^1 | Feature flags | both | config (Later) | features (scope column, no tenant FK) → central | None now. Scope flags with Feature::for($tenant); the features table stays central (do not add tenant_id). |
saloonphp/saloon + laravel-plugin ^4 | Outbound client to the banking-rails API | both | code (Later) | — | Later (critical for isolation): resolve per-tenant provider credentials from tenant() at request time; never reuse a connector authed for one tenant across tenants. |
spatie/laravel-permission ^8 | RBAC | central (now) | code (Later) | roles,permissions,model_has_*,role_has_permissions → central | Central operator RBAC now (no RLS). For per-tenant client roles use spatie teams (team_id = tenant_id), not RLS on these tables. |
spatie/laravel-webhook-client ^3 | INBOUND webhooks (from provider) | central→tenant | code (Later) | webhook_calls → central | Inbound endpoint is central (provider can't send X-Tenant); store raw call centrally, resolve tenant from payload, then tenancy()->run($tenant, …) to process. |
spatie/laravel-webhook-server ^3 | OUTBOUND webhooks | both | code (Later) | — (job-based, no table) | Dispatch within tenant context so jobs carry the tenant (via QueueTenancyBootstrapper). Per-tenant signing secrets later. |
spatie/laravel-data ^4 | DTOs | both | none | — | None. Wrapping RLS-scoped models needs no special handling. |
spatie/laravel-notification-log ^1 | Logs sent notifications | both | config (Later) | notification_log_items → central (now) | Consider adding tenant_id (auto-RLS) if logs carry tenant PII. |
dedoc/scramble ^0.13 | OpenAPI docs | central | config (Later) | — | Docs UI central + protected. Document the X-Tenant header as a required param for the tenant API; scope Scramble::routes() to the API. |
laravel/tinker ^3 | REPL | both | none | — | None (tenancy()->initialize() manually when needed). |
livewire/livewire ^4 + flux ^2 | Backoffice UI | central | none | — | None — central only. If ever used in tenant context, make the Livewire update route universal + tenancy-aware. |
| pest/phpunit/mockery/collision/faker | Test suite | — | critical | — | Done. Postgres test DB + tests/bootstrap.php; RLS tests avoid RefreshDatabase (D14). |
laravel/pint ^1 | Code style | — | none | — | None. |
laravel/boost ^2 (+ roster,mcp,sentinel) | Dev MCP / tooling | — | none | — | None. |
laravel/pail ^1 | Log viewer | — | none | — | None. |
laravel/chisel ^0.1, laravel/pao ^1 | Dev tooling (code pruning; agent test output) | — | none | — | None. |
laravel/sail ^1 | Docker dev (unused — custom Docker) | — | none | — | None. |
5. What was built (file manifest)
Note: the
app/...androutes/...paths in the early (pre-D19) entries below predate the modularization (D19) — application code now lives underapp-modules/<module>/src(e.g.app-modules/tenancy/src/Models/Tenant.php,app-modules/identity/src/Http/Controllers/Api/…,app-modules/identity/routes/identity-routes.php). For the current layout + file map, use thestables-app-developmentskill (reference/overview-and-architecture.md). The entries are kept as the historical record of what was decided when.
config/tenancy.php— RLS profile (bootstrappers,App\Models\Tenant, request-data identification,stables.current_tenant, package asset routes off).app/Models/Tenant.php—HasDatabase+TenantWithDatabase(required bytenants:rls).app/Providers/TenancyServiceProvider.php— multi-DB jobs stripped;scopeByDefault = true.app/Http/Middleware/EnsureUserBelongsToCurrentTenant.php+tenant.memberalias.routes/tenant.php— tenant REST API (request-data identification, stateless).database/migrations/2019_09_15_000010_create_tenants_table.php— UUID PK (domains migration + multi-DBmigrations/tenant/removed).config/database.phpdefault →pgsql;.env/.env.example→TENANCY_RLS_*.docker/dev-start-app.sh— runstenants:rlsaftermigrate.docker/postgres/init/01-create-test-database.sql+ compose mount +make test-db.phpunit.xml+tests/bootstrap.php— Postgres test DB, forced test env, Modules suite.- Client auth (D15):
config/sanctum.php,config/cors.php,config/auth.php(clientguard +client_usersprovider),app/Models/ClientUser.php+ factory +client_usersmigration,app/Http/Controllers/Api/ClientAuthController.php,personal_access_tokensmigration;SANCTUM_STATEFUL_DOMAINS/SESSION_DOMAIN/CORS_ALLOWED_ORIGINSenv. - Tenant discovery (D16):
routes/api.php+app/Http/Controllers/Api/TenantDiscoveryController.php; central API routing enabled inbootstrap/app.php. - Versioning + Turnstile (D17, D18):
config/api.php;/api/v1/prefixes inroutes/api.phproutes/tenant.php;config/turnstile.php+app/Http/Middleware/VerifyTurnstile.php(aliasturnstile);TURNSTILE_*env.
- Tests:
tests/Feature/Tenancy/—RowLevelSecurityIsolationTest(RLS canary),EnsureUserBelongsToCurrentTenantTest(guard),TenantApiIdentificationTest(HTTP identification),ClientAuthTest(Sanctum SPA),TenantDiscoveryTest(email-first),TurnstileTest(challenge),app-modules/example/**(modular proof).
6. Conventions (for the team)
- Tenant-scoped table: add
foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete(). It is auto-policied bytenants:rls. AddFillsCurrentTenantto its model. - Central/reference table (countries, currencies, fee schedules, etc.): no
tenant_id. - A table with a FK toward tenants that must stay central: mark the column with a
no-rlsDB comment. - After any migration:
php artisan tenants:rls(dev start + deploy already do this). - Altering a table that has a policy:
tenancy()->dropRLSPolicies('table')in the migration, then re-runtenants:rls. - Modules: put tenant-scoped migrations/models in the module; they integrate with RLS
automatically. Central routes → module
routes/*.php(loaded by the module provider); tenant API routes → applyInitializeTenancyByRequestData(+ later auth +tenant.member). - Every tenant-scoped model gets an isolation test (create as A, read as B → empty).
- Where new code goes (modular): new bounded contexts → a new module (
php artisan make:module X); shared/cross-cutting code → Foundation; tenancy concerns → Tenancy; users & auth → Identity; operator UI → Backoffice. Keepapp/empty. A module's routes go in itsroutes/*.php(auto-loaded); a module owning tenant-scoped tables ships them with atenant_idFK sotenants:rlspolicies them automatically. - Module views/components: drop Blade in
app-modules/<m>/resources/views→ access as<m>::view.name; Blade components as<x-<m>::name>(modular auto-registers both). A module's Livewire component shouldrender()its<m>::view explicitly.
7. Changelog
- Branch
feat/tenancy-rls-foundation. Installedstancl/tenancy:dev-master(v4 RLS) +internachi/modular:^3.0. Configured single-DB Postgres RLS, request-data identification, central/tenant split. Provisioned RLS role + test DB. Recorded D1–D14; full package inventory. - Added client auth (D15):
laravel/sanctum:^4.3SPA cookie auth for the tenant API, tenant-scopedClientUser(client_users, RLS),clientguard, CORS. - Added email-first tenant discovery (D16): central
POST /api/tenant-discoveryresolves an email → tenant id(s) before login. - Added API versioning (D17,
/api/v1/, v1 current default) and Cloudflare Turnstile (D18) on the unauthenticated login + discovery endpoints. - Refactored into a modular monolith (D19): emptied
App\; created Foundation, Tenancy, Identity, Backoffice modules underapp-modules/*; module-owned routes/providers. 58 tests green (the −2 vs before is the removed throwawayexamplemodule) + live smoke. - Moved Backoffice Blade views + Livewire into the module under the
backoffice::view namespace (D20); shared UI kit kept global. 58 tests green + live UI smoke (/,/login,/register). - Studied the legacy
stablesxyz/corerepo (D21): adopted architecture tests (strict types, return types, module boundaries, tenant isolation — 14 assertions) + atenants:verify-isolationcommand/test; captured per-module + Action/Integration-contract conventions, the 18-module domain roadmap, and the Payment double-entry-ledger blueprint. Skipped CQRS (use Actions); JSON:API envelope left as a documented decision. - Adopted the CQRS command/query bus as the universal gate+audit checkpoint, the JSON:API
response envelope/error shape, and the legacy repo's Scramble type extensions (D22, reverses
D21's CQRS-skip + resolves the API-contract decision). Built
Modules\Foundation\Bus\*(contracts, abstract messages, Laravel buses, audit channel, facades),Modules\Foundation\Http\*(JSON:API resource base, envelope, exception handler, correlation middleware), andModules\Foundation\ Scramble\*(7 extensions). Converted Identity (client auth + discovery) to the bus + JSON:API as the reference implementation; addedCqrsTest+CommandBusTest. 79 tests green;scramble:exportclean.