Files
php-qml/docs/architecture.md
magdev da048434b8 docs: rewrite README + add comprehensive docs/
README is now tight and link-heavy: 60-second tour, then deep links
into docs/. The wall of detail moved out.

docs/ covers the framework end-to-end:
- getting-started.md — prerequisites by distro (Tumbleweed, Fedora,
  Debian/Ubuntu, Arch), full first-run walkthrough, troubleshooting.
- architecture.md — process pair, transport, dev/bundled mode.
- update-semantics.md — state machine + optimistic mutations + key
  round-tripping.
- reactive-models.md — ReactiveListModel, ReactiveObject, Mercure
  dual-publish.
- makers.md — make:bridge:resource/command/window.
- dev-workflow.md — hot reload (PHP + QML), dev console, editor
  configs, bridge:doctor, snapshot/integration test loops, perfsmoke.
- bundled-mode.md — supervisor, per-session secret rotation,
  first-launch migrations, auto-update wiring.
- packaging-linux.md — make appimage, build-appimage.sh CLI,
  AppImageUpdate sidecar, perfsmoke budgets, release CI, bundle-size
  breakdown.
- qml-api.md / php-api.md — exhaustive symbol reference with all
  Q_PROPERTY/Q_INVOKABLE/signals + every public PHP service / attribute
  / command.
- configuration.md — every env var (host, Symfony, dev script,
  packaging script, perfsmoke), every CLI flag (php-qml-init,
  build-appimage.sh), make targets, default ports/paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 22:18:37 +02:00

163 lines
9.2 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `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/<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).