Skip to main content

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).
  • users split — DONE: central operators in users; tenant client users in the RLS-scoped client_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, and CORS_ALLOWED_ORIGINS to 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 the X-Tenant header + 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 via tenancy()->run($tenant, …).
  • Pin a stable stancl/tenancy v4 tag once published (currently dev-master).
  • Move the shared UI kit (resources/views/layouts, generic components, 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.

PackageRoleRuns inInteractionTables → scopingAction
stancl/tenancy dev-masterTenancy engine (RLS)bothcriticaltenants → centralDone. Configured for single-DB RLS; domains/impersonation unused.
internachi/modular ^3.0Modular monolithbothconfigDone. 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.37Auth backend (login/2FA/passkeys)centralconfigusers,password_reset_tokens → central; passkeys → centralFortify stays central (backoffice operators). Do not enable FortifyRouteBootstrapper. Client (tenant) auth is separate — see the Sanctum row + ClientUser (D15).
laravel/sanctum ^4.3Client SPA auth (cookie/session)tenantcriticalpersonal_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 passkeyscentralnone (Later)passkeys → central (tied to operator users)None now. If client users get passkeys, that table follows the client-users (tenant) decision.
laravel/horizon ^5Queue workers/dashboard (Valkey)bothcritical— (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 ^1Feature flagsbothconfig (Later)features (scope column, no tenant FK) → centralNone now. Scope flags with Feature::for($tenant); the features table stays central (do not add tenant_id).
saloonphp/saloon + laravel-plugin ^4Outbound client to the banking-rails APIbothcode (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 ^8RBACcentral (now)code (Later)roles,permissions,model_has_*,role_has_permissions → centralCentral 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 ^3INBOUND webhooks (from provider)central→tenantcode (Later)webhook_calls → centralInbound 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 ^3OUTBOUND webhooksbothcode (Later)— (job-based, no table)Dispatch within tenant context so jobs carry the tenant (via QueueTenancyBootstrapper). Per-tenant signing secrets later.
spatie/laravel-data ^4DTOsbothnoneNone. Wrapping RLS-scoped models needs no special handling.
spatie/laravel-notification-log ^1Logs sent notificationsbothconfig (Later)notification_log_items → central (now)Consider adding tenant_id (auto-RLS) if logs carry tenant PII.
dedoc/scramble ^0.13OpenAPI docscentralconfig (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 ^3REPLbothnoneNone (tenancy()->initialize() manually when needed).
livewire/livewire ^4 + flux ^2Backoffice UIcentralnoneNone — central only. If ever used in tenant context, make the Livewire update route universal + tenancy-aware.
pest/phpunit/mockery/collision/fakerTest suitecriticalDone. Postgres test DB + tests/bootstrap.php; RLS tests avoid RefreshDatabase (D14).
laravel/pint ^1Code stylenoneNone.
laravel/boost ^2 (+ roster,mcp,sentinel)Dev MCP / toolingnoneNone.
laravel/pail ^1Log viewernoneNone.
laravel/chisel ^0.1, laravel/pao ^1Dev tooling (code pruning; agent test output)noneNone.
laravel/sail ^1Docker dev (unused — custom Docker)noneNone.

5. What was built (file manifest)

Note: the app/... and routes/... paths in the early (pre-D19) entries below predate the modularization (D19) — application code now lives under app-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 the stables-app-development skill (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.phpHasDatabase + TenantWithDatabase (required by tenants:rls).
  • app/Providers/TenancyServiceProvider.php — multi-DB jobs stripped; scopeByDefault = true.
  • app/Http/Middleware/EnsureUserBelongsToCurrentTenant.php + tenant.member alias.
  • 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-DB migrations/tenant/ removed).
  • config/database.php default → pgsql; .env/.env.exampleTENANCY_RLS_*.
  • docker/dev-start-app.sh — runs tenants:rls after migrate.
  • 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 (client guard + client_users provider), app/Models/ClientUser.php + factory + client_users migration, app/Http/Controllers/Api/ClientAuthController.php, personal_access_tokens migration; SANCTUM_STATEFUL_DOMAINS/SESSION_DOMAIN/CORS_ALLOWED_ORIGINS env.
  • Tenant discovery (D16): routes/api.php + app/Http/Controllers/Api/TenantDiscoveryController.php; central API routing enabled in bootstrap/app.php.
  • Versioning + Turnstile (D17, D18): config/api.php; /api/v1/ prefixes in routes/api.php
    • routes/tenant.php; config/turnstile.php + app/Http/Middleware/VerifyTurnstile.php (alias turnstile); 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 by tenants:rls. Add FillsCurrentTenant to 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-rls DB 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-run tenants: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 → apply InitializeTenancyByRequestData (+ 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. Keep app/ empty. A module's routes go in its routes/*.php (auto-loaded); a module owning tenant-scoped tables ships them with a tenant_id FK so tenants:rls policies 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 should render() its <m>:: view explicitly.

7. Changelog

  • Branch feat/tenancy-rls-foundation. Installed stancl/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.3 SPA cookie auth for the tenant API, tenant-scoped ClientUser (client_users, RLS), client guard, CORS.
  • Added email-first tenant discovery (D16): central POST /api/tenant-discovery resolves 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 under app-modules/*; module-owned routes/providers. 58 tests green (the −2 vs before is the removed throwaway example module) + 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/core repo (D21): adopted architecture tests (strict types, return types, module boundaries, tenant isolation — 14 assertions) + a tenants:verify-isolation command/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), and Modules\Foundation\ Scramble\* (7 extensions). Converted Identity (client auth + discovery) to the bus + JSON:API as the reference implementation; added CqrsTest + CommandBusTest. 79 tests green; scramble:export clean.

← Decision log index