- The **Qt host** is the long-lived parent. It owns the window, input, and rendering. It also owns the lifecycle of the FrankenPHP child.
- **FrankenPHP** runs Symfony in *worker mode*: PHP boots once, the kernel stays warm, and incoming HTTP requests reuse the bootstrapped container. That's how cold-start stays under ~2 s for a non-trivial Symfony app.
- They talk over loopback (`127.0.0.1`). In dev mode the port is `8765` by default; in bundled mode the host negotiates a free ephemeral port at launch (so two installed apps don't collide). Either way it's loopback only — no network exposure.
- The bridge is a **wire protocol**, not an FFI layer. Either side can be replaced (the Qt host could be a different GUI; the backend could be a different language) without changing the other.
Why split? HTTP is request/response — natural fit for "do this and tell me the answer". SSE is a long-lived stream that survives across hundreds of mutations without per-mutation handshake overhead. Combining them gives optimistic UI without WebSockets and without the host having to long-poll.
### HTTP request shape
```
POST /api/todos
Authorization: Bearer <BRIDGE_TOKEN>
Content-Type: application/json
Idempotency-Key: <random per mutation>
{"title":"buy milk","done":false}
```
The `Idempotency-Key` is generated by the QML side (`ReactiveListModel.invoke()` does this for you). It round-trips through the controller into `ModelPublisher`, which sets it as `correlationKey` on every Mercure event triggered by that request. The QML side matches the echo against any in-flight optimistic mutation — see [Update semantics](update-semantics.md).
### Mercure dual-publish
Every `#[BridgeResource]` change publishes **two** events:
Collection topic feeds `ReactiveListModel`; entity topic feeds `ReactiveObject`. They carry the same payload because the alternative — a single topic with filtering — meant every list subscriber pays for every entity event in the system, even ones it doesn't care about.
The `version` counter is monotonic per resource and lets the client detect dropped events: if the next event jumps the version by more than 1, the client falls back to a full re-fetch.
## Dev mode vs bundled mode
The Qt host auto-detects which one it's in based on the `BRIDGE_URL` env var.
| Mercure JWT secret | Static — declared in `.env` | Per-session random (≥256 bits for `lcobucci/jwt`) |
| Migrations | `make:migration` + `migrate` on demand | Run automatically before the supervisor spawns FrankenPHP |
| Hot reload | FrankenPHP `--watch` reloads on file change | No watcher — production-style |
| Crash recovery | User reruns `make dev` | Supervisor restarts (5 retries before going Offline) |
Dev mode is what `make dev` boots; bundled mode is what AppImage end-users see. The same QML code paths work both ways — `BackendConnection` exposes mode via `BackendConnection.mode` so the app can show different UI when relevant (e.g. the dev console hint).
See [Bundled mode](bundled-mode.md) for the supervisor details and [Dev workflow](dev-workflow.md) for what `make dev` actually runs.
## Lifecycle
```
BackendConnection ctor
│
┌──────┴──────┐
│ │
BRIDGE_URL set? │
│ │
┌── yes ────┐ ┌── no ──┐
▼ │ ▼ │
initDevMode │ initBundledMode
│ │ (fork frankenphp,
│ │ run migrations,
│ │ set up token)
│ │ │
└─────┬─────┘────────┘
▼
Connecting ── probe /healthz ──▶ Online
│ │
│ <100ms latency │
│ ◀── Reconnecting ◀───────────┘
│ │
└─── Offline ◀── 30s timeout ──┘
```
State changes drive the QML UI:
- **Connecting** — first probe in flight; default `AppShell` shows nothing (just the user's content).
- **Online** — green dot. Mutations enabled.
- **Reconnecting** — orange banner via `AppShell`. UI input still works but mutations queue.
- **Offline** — modal overlay via `AppShell` with a Retry button calling `BackendConnection.restart()`.
See [Update semantics](update-semantics.md) for the full state machine, including how optimistic mutations interact with each state.
- **No QML→PHP method calls** — every interaction is HTTP. Method calls would mean sharing object lifecycles across processes; that's the cliff php-gtk fell off.
- **No serialised PHP objects on the wire** — JSON only. The bundle uses Symfony's serializer with a normalizer chain.
- **No persistent connection state in the host** — the host is allowed to crash and recover; the child should be too. SQLite file is the only durable thing.
- **No background workers in the child** — workers belong to the application. The bundle only owns request-scope code.
## Where to dig deeper
- [Update semantics](update-semantics.md) — state machine, optimistic mutations, idempotency keys.
- [Reactive models](reactive-models.md) — how the QML models stay in sync.