# Architecture This page is a tour of how the two halves of a php-qml app fit together. For *why* the design landed where it did, see [PLAN.md §1–§5](../PLAN.md). ## Process pair A running app is two processes: ``` ┌─────────────────────────┐ HTTP /api/* ┌────────────────────────┐ │ Qt host │ ─────────────────────────▶ │ FrankenPHP (worker) │ │ QML engine │ │ Symfony app │ │ BackendConnection │ ◀──── Mercure SSE ──────── │ ModelPublisher │ │ ReactiveListModel │ app://model/ │ Doctrine │ │ MercureClient │ app://model// │ │ └─────────────────────────┘ └────────────────────────┘ parent child ``` - 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 `127.0.0.1:8765` by default. Loopback only — there is 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. ## Transport Two channels: | Channel | Direction | Use | | --- | --- | --- | | HTTP | host → child | Commands and queries: `GET /api/todos`, `POST /api/mark-all-done`, `PATCH /api/todos/` | | Mercure SSE | child → host | State push: entity create/update/delete, command-completion echoes | 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 Content-Type: application/json Idempotency-Key: {"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: ```json { "topic": "app://model/todo", "data": { "op":"upsert", "id":"01HXY…", "data":{ "title":"buy milk", … } }, "correlationKey": "my-key-1", "version": 42 } ``` ```json { "topic": "app://model/todo/01HXY…", "data": { "op":"upsert", "id":"01HXY…", "data":{ … } }, "correlationKey": "my-key-1", "version": 42 } ``` 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. | | Dev mode | Bundled mode | | ------------------------ | --------------------------------------------------- | ----------------------------------------------------------- | | Trigger | `BRIDGE_URL=http://127.0.0.1:8765` set | `BRIDGE_URL` unset (typical AppImage launch) | | FrankenPHP lifecycle | Started by `make dev`, killed by Ctrl-C | Spawned by `BackendConnection`, supervised + auto-restarted | | Bridge token | Static — `BRIDGE_TOKEN=devtoken` in `.env` | Per-session random (32 bytes, base64url) | | Database | Project-local `symfony/var/data.sqlite` | User-local `~/.local/share//var/data.sqlite` | | 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. ## Where the code lives ``` framework/qml/src/ BackendConnection.{h,cpp} lifecycle, supervisor, update probes MercureClient.{h,cpp} SSE subscription with reconnect ReactiveListModel.{h,cpp} QAbstractListModel fed by HTTP + SSE ReactiveObject.{h,cpp} single-entity twin (for detail views) SingleInstance.{h,cpp} QLocalServer-based lock + arg forwarding framework/qml/qml/ AppShell.qml reconnecting banner + offline overlay RestClient.qml HTTP client wrapper DevConsole.qml in-window FrankenPHP child output framework/php/src/ BridgeBundle.php bundle wiring ModelPublisher.php dual-publish to Mercure Attribute/BridgeResource.php attribute the subscriber looks for EventSubscriber/ Doctrine + Symfony event subscribers Maker/ maker-bundle integrations Command/BridgeDoctorCommand bridge:doctor console command ``` ## What the framework deliberately doesn't do - **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. - [Bundled mode](bundled-mode.md) — supervisor + per-session secrets. - [PLAN.md](../PLAN.md) §3 (run modes), §5 (update semantics), §7 (transport types).