Infrastructure components, data flows, and the trade-offs behind each choice. Use this to understand what you need, what's optional, and what to swap when you scale.
Serves two distinct roles:
async Doctrine transport). PostgreSQL LISTEN/NOTIFY then wakes the worker the moment a job is enqueued instead of waiting on the poll interval — but the notifications aren't persistent, so the worker still polls the table as a fallback, which is what keeps a job enqueued while it's down (e.g. mid-deploy) from being lost.Server-Sent Events hub used to stream live updates from PHP to the browser:
Routed same-origin at /.well-known/mercure to avoid CORS. The browser gets a JWT cookie via Authorization::setCookie to authorize subscriptions.
ignore_user_abort(true) ensuring the assistant message persists even if the browser closes mid-stream.
Encrypts and decrypts per-user AI provider API keys at rest. The api-keys endpoint provides transit encryption — the key material never leaves the vault.
APP_SECRET-derived encryption via sodium. Less secure since the key lives in the environment, but works without external services.
Shared mount at /storage/audio for uploaded audio files. Both the web container (receives uploads) and the transcription worker (sends audio to Whisper) need access to the same files. Without shared storage, the worker can't read what the web container wrote.
Usage stays flat rather than growing: the worker deletes each file once its transcript is saved. Only unresolved recordings — transcription failed, or returned an empty transcript — keep their audio, so it can be retried or downloaded.
Full-text search alternative to the database's built-in FTS. Controlled by the SEARCH_PROVIDER environment variable — by default (any value other than opensearch) it uses the database's own FTS, auto-detecting PostgreSQL vs SQLite; set it to opensearch to switch. Only worth adding when recording volume makes the database's FTS a bottleneck.
Could serve as a Messenger transport or session cache. Not needed at current scale since PostgreSQL handles the message queue and Mercure handles all real-time IPC.
Could replace the Doctrine transport for Symfony Messenger if message volume or delivery guarantees require it. Messenger supports AMQP out of the box.
Mercure (SSE) is fire-and-forget: if no subscriber is listening when an event is published, it's gone. That's fine for live UI flourishes — if a chat token delta is missed, the persisted assistant message in PostgreSQL is the canonical record on the next page load.
Transcription jobs need durability: if the worker is restarting during a deploy when a recording is uploaded, the job must not be lost. Symfony Messenger with the Doctrine transport stores messages in a database table and delivers them when the worker comes back. It also provides automatic retry with backoff and a failure transport for dead-letter inspection.
Rule of thumb: use Mercure for real-time fan-out where loss is tolerable, Messenger for background jobs where reliability matters, PostgreSQL for canonical state.
Tempting — the worker already publishes to Mercure, so why not have it subscribe for new jobs instead of leaning on PostgreSQL LISTEN/NOTIFY? Nothing stops a server-side process from subscribing to a hub; Mercure isn't browser-only, and server-to-server eventing over it is perfectly legitimate. But Mercure is broadcast pub/sub, and a work queue needs three things it doesn't provide:
SELECT … FOR UPDATE SKIP LOCKED or an AMQP work queue.Last-Event-ID, but that's a best-effort buffer, not a log a job can be trusted to survive an arbitrary outage in.So Mercure could only ever be a wake signal sitting on top of the durable table — exactly LISTEN/NOTIFY's role, but as custom code with the hub now in the job path. The real distinction is broadcast events vs. work distribution, not internal vs. external: Mercure for fan-out where loss is tolerable (including between server components), a broker or the Doctrine table for work that must be processed once and never lost.
From browser audio recording to searchable text. Recording length is bounded by OpenAI's 25 MB per-request transcription limit, not by a timer: capturing mono 24 kbps Opus — all Whisper can use, since it downsamples to 16 kHz mono regardless — puts roughly 2 hours 20 minutes inside a 24 MB cap, which is why chunking long audio and reconciling the transcripts is not part of this flow.
The chat lives inline on the recording page. There is one AiSession per (user, recording) and a child AiMessage per turn. When the user posts a message, the same HTTP request persists it, calls the AI provider, and publishes deltas to a per-session Mercure topic as they arrive. The browser subscribes to that topic and stitches deltas onto the assistant bubble in real time. ignore_user_abort(true) ensures the assistant message gets persisted even if the browser closes mid-stream.
Everything runs on PostgreSQL + Mercure today. Here's when each component should be replaced.
| Bottleneck | Current | Swap to | When |
|---|---|---|---|
| Message queue throughput | Doctrine transport (PostgreSQL) | RabbitMQ (AMQP) | High message volume, need dead-letter queues, priority routing |
| Concurrent rewriting chats | PHP-FPM worker held during the AI stream | FrankenPHP worker mode | FPM worker pool exhausted under heavy concurrent chat load — FrankenPHP keeps the same worker available across requests |
| Full-text search | PostgreSQL FTS | OpenSearch | Large recording corpus, need fuzzy matching, faceted search, or relevance tuning |
| API key encryption | Vault KMS (prod) / sodium (dev) | External KMS (AWS, GCP) | Multi-cloud deployment, compliance requires HSM-backed keys |
| Audio storage | Symfony Cloud network-storage | S3-compatible object store | Need CDN, cross-region replication, or very large file counts |
An earlier iteration ran a full coding agent (pi.dev) inside a browser-side terminal — PTY forwarding, bubblewrap sandboxing, a dedicated worker container, the works. It was the wrong shape for what users actually want here.
The job is "rewrite this transcript" — tighten it, remove filler, restructure, summarize. Not "execute commands on a sandbox," not "research the web," not "build software." Once you accept that, almost everything else simplifies.
Replies arrive as token deltas, get persisted to ai_message, and render as sanitized markdown. The application never executes anything the model produces. That removes the whole sandboxing problem — no bubblewrap, no per-user filesystem isolation, no PTY multiplexing, no worker container.
Each (user, recording) pair maps to exactly one persisted AiSession. Reload the page and you get the same thread back; open it in another tab and Mercure keeps both views in sync. No session list, no "+ New conversation" UI, no session lifecycle states — the data model becomes a parent row plus an ordered list of messages.
The ai-session Messenger transport, the dedicated worker container, the pi.dev binary, the bubblewrap install, the xterm.js terminal, the Revolt event loop dependency, two HTTP control endpoints, and a full second page in the routing tree. The schema lost a status enum and a closed_at column.
Neither Anthropic Messages nor OpenAI Chat Completions has a first-class "skill" parameter — it's a client-side concept (Claude Code, Cursor, etc. all do this). Each Skill is a name + free-form instructions document scoped to a user. Active skills for a session live in an ai_session_skill join table.
Before each provider call, buildSystemPrompt(AiSession) concatenates the base writing-assistant prompt with each active skill's instructions under ## Skill: name headers. That string lands in Anthropic's top-level system field or OpenAI's leading role: system message — both providers treat it as foundational context for every reply. Skills are never visible in the chat history; they only shape the assistant's behavior.
The site keeps itself updated via a weekly CI job that creates a branch, updates dependencies, runs the test suite, and decides what to do based on the results.
An Symfony Cloud cron job runs app:ci-run every Monday at 3 AM. It uses the Symfony Cloud API to create a new environment branch from main, so the update process runs in an isolated copy of production.
The CI job triggers a source operation that runs composer update on the branch. This updates composer.lock and commits the changes. The source operation log captures which packages were updated and whether any have security advisories.
After the source operation, Symfony Cloud deploys the branch. The post_deploy hook runs PHPUnit. If the deploy or tests fail, the branch is left in place for investigation.
If the source operation log mentions security vulnerability advisories, and the tests pass, the branch is automatically merged back into main and cleaned up. A notification email confirms what was merged.
If no security advisories were found, the branch is left open and a notification email is sent with a direct link to review and merge in the Symfony Cloud console. This avoids auto-merging routine updates that might need manual review.
When any step fails (branch creation, source operation, deploy, or tests), the activity log is sent to OpenAI for analysis. The AI identifies the likely root cause and suggests fixes. This analysis is included in the failure notification email, so the developer gets actionable context without having to dig through raw logs.
The chat handler holds a PHP-FPM worker for the duration of an AI completion (typically a few seconds, occasionally tens of seconds for long replies). At low concurrency this is fine. Under sustained load it becomes the scaling pinch point. FrankenPHP — a modern PHP application server that keeps processes alive across requests and embeds Mercure directly — is the planned escape hatch.
FrankenPHP's worker mode keeps a single warm process serving many requests, so a slow streaming reply doesn't tie up an entire FPM child for its lifetime. Concurrent chats cost less.
Publishing chat token deltas becomes an in-process call to a built-in Mercure hub instead of an HTTP round-trip to a separate service. Lower latency per token, fewer moving parts in deployment.
Today we run a web server, a transcription worker, and a standalone Mercure service. With FrankenPHP, the web server absorbs Mercure, leaving the transcription worker as the only sidecar.