Files
php-qml/docs/architecture.md

163 lines
9.4 KiB
Markdown
Raw Permalink Normal View History

# 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).