The README still framed the project as "Phase 5 / pre-v0.1.0" and the docs predated the v0.2.0 surface (typed BridgeOp, public service interfaces, port negotiation, pre-migration auto-backup, bridge:export, periodic auto-update, two new makers, qmltestrunner). Bring them in line with what's actually shipped, and add badges (release, license, PHP, Symfony, Qt, FrankenPHP, CI, platform) to the README so the status is legible at a glance. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
163 lines
9.4 KiB
Markdown
163 lines
9.4 KiB
Markdown
# 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/<name> │ Doctrine │
|
||
│ MercureClient │ app://model/<name>/<id> │ │
|
||
└─────────────────────────┘ └────────────────────────┘
|
||
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 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.
|
||
|
||
## Transport
|
||
|
||
Two channels:
|
||
|
||
| Channel | Direction | Use |
|
||
| --- | --- | --- |
|
||
| HTTP | host → child | Commands and queries: `GET /api/todos`, `POST /api/mark-all-done`, `PATCH /api/todos/<id>` |
|
||
| 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 <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:
|
||
|
||
```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/<app>/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).
|