Architecture

Stack (PHP variant)

The owner asked for a PHP build, so the TypeScript stack in CLAUDE.md §3 is mapped as follows. Every non-negotiable rule in §2 still applies.

CLAUDE.md choiceThis build
pnpm monorepo, Next.js, FastifySingle PHP 8.3 app with a small in-house kernel (app/Core), modules under app/Modules/*, PHP templates
PostgreSQL + PrismaMariaDB 11 via PDO; SQL migrations in database/migrations applied once by bin/migrate
Redis + BullMQDatabase-backed queue (jobs table) + scheduler, driven by bin/worker; QUEUE_DRIVER=sync runs jobs inline
Socket.IO realtimeLightweight JSON polling endpoints (/live/*.json, data-live attribute in assets/js/app.js)
MinIO / S3StorageProvider: local disk under storage/uploads (served via /uploads/*) or S3-compatible SigV4 client
Caddy + Docker ComposeHestiaCP: Nginx → Apache → PHP-FPM, Let's Encrypt managed by Hestia
Vitest / Playwrightbin/test (in-process HTTP tests through the full kernel, each test in a rolled-back transaction)

Request lifecycle

index.phpApp::boot() (loads .env, config/*.php, all modules) → App::handle(Request):

  1. Session start (DB-backed, signed cookie cos_session, SameSite=Lax, httpOnly).
  2. Router match ({param} / {param:regex} segments).
  3. Middleware pipeline: auth, guest, csrf, tenant, role:<min>, platform_admin, throttle[:bucket], api_key, server_key, json.
  4. Controller returns a Response (or string/array → html/json).
  5. Errors: ValidationException → 422 JSON or redirect-with-errors; HttpException → JSON or themed error page.

Multi-tenancy

  • tenants is the paying account. memberships link users to tenants with owner|admin|moderator|staff|member.
  • App\Core\TenantScope is the only way handlers touch tenant-owned tables. It forces tenant_id on every read/write, rejects unregistered tables, and requires a {tenant} placeholder in raw SQL. tests/TenantIsolationTest.php proves A cannot read/update/delete B.
  • The dashboard resolves the active tenant from the session (ResolveTenant middleware); API keys and server keys resolve it from the key row.

Modules

Each folder in app/Modules/<Name> may contain module.php (priority + sidebar nav entries), boot.php (bind services, register queue handlers and scheduler tasks), routes.php, Controllers, Services, views (rendered as name::view). Modules never import each other's internals; cross-module calls go through services bound on the app container ($app->has('automation'), $app->make('mailer'), discord, ai, payments, storage, notify, outbound).

Providers

Every external integration sits behind an interface in app/Providers/*: PaymentProvider (Stripe/mock), VerificationProvider (mock until confirmed), SocialProvider (10 platforms), AIProvider (Anthropic/mock), ImageProvider (mock), StorageProvider (local/S3), EmailProvider (log/SMTP/Resend). Missing credentials disable a provider; nothing crashes.

Background work

  • jobs table: push(type, payload, runAt, queue, dedupeKey); bin/worker claims due jobs with a reservation token, retries with exponential backoff, and marks failures after max_attempts.
  • Scheduler tasks (registered in boot.php) run from the worker tick: token refresh, live polling, scheduled posts, offline detection, delivery retries, analytics roll-ups, cleanup.
  • Webhooks (Stripe, verification) are recorded in webhook_events (unique provider,event_id) before processing, so replays are no-ops.

Security

Argon2id passwords, TOTP 2FA with recovery codes, CSRF on all cookie-authenticated mutations, DB-backed rate limits (auth/api/ai/ingest buckets), API keys stored as SHA-256 hash + prefix, OAuth tokens and secrets encrypted with AES-256-GCM (ENCRYPTION_KEY), uploads sniffed + re-encoded to WebP, JSON logs with secret redaction, audit log for money/permission/admin actions.