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 choice | This build |
|---|---|
| pnpm monorepo, Next.js, Fastify | Single PHP 8.3 app with a small in-house kernel (app/Core), modules under app/Modules/*, PHP templates |
| PostgreSQL + Prisma | MariaDB 11 via PDO; SQL migrations in database/migrations applied once by bin/migrate |
| Redis + BullMQ | Database-backed queue (jobs table) + scheduler, driven by bin/worker; QUEUE_DRIVER=sync runs jobs inline |
| Socket.IO realtime | Lightweight JSON polling endpoints (/live/*.json, data-live attribute in assets/js/app.js) |
| MinIO / S3 | StorageProvider: local disk under storage/uploads (served via /uploads/*) or S3-compatible SigV4 client |
| Caddy + Docker Compose | HestiaCP: Nginx → Apache → PHP-FPM, Let's Encrypt managed by Hestia |
| Vitest / Playwright | bin/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):
- Session start (DB-backed, signed cookie
cos_session, SameSite=Lax, httpOnly). - Router match (
{param}/{param:regex}segments). - Middleware pipeline:
auth,guest,csrf,tenant,role:<min>,platform_admin,throttle[:bucket],api_key,server_key,json. - Controller returns a
Response(or string/array → html/json). - Errors:
ValidationException→ 422 JSON or redirect-with-errors;HttpException→ JSON or themed error page.
Multi-tenancy
tenantsis the paying account.membershipslink users to tenants withowner|admin|moderator|staff|member.App\Core\TenantScopeis the only way handlers touch tenant-owned tables. It forcestenant_idon every read/write, rejects unregistered tables, and requires a{tenant}placeholder in raw SQL.tests/TenantIsolationTest.phpproves A cannot read/update/delete B.- The dashboard resolves the active tenant from the session (
ResolveTenantmiddleware); 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
jobstable:push(type, payload, runAt, queue, dedupeKey);bin/workerclaims due jobs with a reservation token, retries with exponential backoff, and marks failures aftermax_attempts.Schedulertasks (registered inboot.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(uniqueprovider,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.