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>
9.2 KiB
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.
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:8765by 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.
Mercure dual-publish
Every #[BridgeResource] change publishes two events:
{
"topic": "app://model/todo",
"data": { "op":"upsert", "id":"01HXY…", "data":{ "title":"buy milk", … } },
"correlationKey": "my-key-1",
"version": 42
}
{
"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 for the supervisor details and Dev workflow 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
AppShellshows 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
AppShellwith a Retry button callingBackendConnection.restart().
See Update semantics 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 — state machine, optimistic mutations, idempotency keys.
- Reactive models — how the QML models stay in sync.
- Bundled mode — supervisor + per-session secrets.
- PLAN.md §3 (run modes), §5 (update semantics), §7 (transport types).