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.php → App::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. When a raw query joins two tenant-owned tables, qualify the placeholder with the alias (WHERE t.{tenant} …) or MariaDB rejects tenant_id as ambiguous. tests/TenantIsolationTest.php proves A cannot read/update/delete B.
  • Two safety nets run with every bin/test: tests/RouteSmokeTest.php requests every registered GET route as anonymous / member / owner+platform-admin (ids and slugs filled from the seeded demo workspace) and fails on any 5xx; tests/FormsTest.php submits the real rendered forms the way a browser would (via TestCase::submitForm(), real CSRF token, no bypass) so view-layer regressions in field names or hidden inputs are caught.
  • 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), PodProvider (Printful/Printify/Gooten/mock — per-workspace keys, docs/merch.md). 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.

Sign in with Google / Discord

SocialLogin (Auth module) handles /auth/{google|discord} → provider → /auth/{provider}/callback. It is

separate from the workspace "Connect account" flow: it asks only for identity and email (openid email profile

/ identify email), stores no token, and records the link in user_identities (one row per provider account).

Credentials come from the same GOOGLE_CLIENT_ID/SECRET and DISCORD_CLIENT_ID/SECRET as the social providers;

a provider without both is simply not offered. The callback checks a session-bound state (15 minutes), then:

  1. a known identity → sign that user in (2FA honoured);
  2. a verified provider email matching an existing account → link it and sign in (and mark the email verified);
  3. otherwise a new account with email_verified_at set, a random password and password_set_at = NULL → onboarding.

An unverified or missing provider email never creates or attaches to an account. Signed in, the same URL links

the provider to the current account (Account → Sign-in methods, with Unlink); the last provider cannot be

unlinked while password_set_at is NULL, so nobody locks themselves out. Register, password change and reset

set password_set_at. Redirect URIs to register with the providers: https://krunc.com/auth/google/callback

and https://krunc.com/auth/discord/callback. The Google consent screen must be "In production" for people

outside the test-user list to sign in; the basic scopes need no verification (the YouTube scopes used by

Connect do, and show Google's unverified-app warning until then).