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>
7.2 KiB
Reactive models
The QML side has two reactive primitives that wrap a Mercure-fed REST endpoint into a Qt model: ReactiveListModel for collections, ReactiveObject for single entities. They are what makes the headline make:bridge:resource workflow turn into a live UI without any handwritten cross-side glue.
For the wire-level details — dual-publish topics, version counter, correlationKey — see Update semantics.
ReactiveListModel
QAbstractListModel subclass that:
- Issues a
GET <source>onceBackendConnection.connectionState === Online. - Subscribes to
<topic>over Mercure SSE. - Applies events to the local rows.
- Exposes a
pendingrole for optimistic mutations.
import PhpQml.Bridge
ReactiveListModel {
id: todoModel
baseUrl: BackendConnection.url
token: BackendConnection.token
source: "/api/todos" // initial GET + mutation endpoint
topic: "app://model/todo" // collection Mercure topic
}
ListView {
model: todoModel
delegate: ItemDelegate {
required property string id
required property string title
required property bool done
required property bool pending // ← provided by the model
opacity: pending ? 0.5 : 1.0
// …
}
}
Properties
| Property | Type | Notes |
|---|---|---|
baseUrl |
string | Usually BackendConnection.url. |
token |
string | Bearer token. Reads BackendConnection.token. |
source |
string | REST path, e.g. /api/todos. Initial GET hits this; mutations append /<id>. |
topic |
string | Mercure topic. Convention: app://model/<resource-name>. |
idField |
string | Defaults to id. Override if your resource keys on slug, uuid, etc. |
ready |
bool | true once the initial GET has populated the model. Useful for empty-state UI. |
Roles
Every entity in the response becomes a row. Each entity field becomes a role of the same name. On top of that the model adds:
pending—truewhile an optimistic mutation against this row is in flight.
Methods
todoModel.invoke(method, urlSuffix, body, optimistic)
| Arg | Description |
|---|---|
method |
"POST", "PATCH", "DELETE", … |
urlSuffix |
Appended to source. Pass "" for collection-level mutations (e.g. POST), "/" + id for entity-level. |
body |
JS object — serialised as JSON. Pass null for verbs without a body. |
optimistic |
`{ op: "upsert" |
invoke() mints a uuidv7 Idempotency-Key and returns it. The header round-trips into Mercure as correlationKey so the model can reconcile its in-flight optimistic patch against the server echo.
Signals
| Signal | Fired on |
|---|---|
commandFailed(key, status, problem) |
HTTP failure. Local optimistic patch already rolled back; problem is the parsed Symfony Problem-Details JSON if available. |
commandTimedOut(key) |
HTTP succeeded but Mercure echo never arrived within 5 s. Model has re-fetched. |
Event handling
When a Mercure event lands on <topic>:
{ "op": "upsert", "id": "01HX…", "data": { "title": "buy milk", "done": false }, "correlationKey": "…", "version": 42 }
upsert— find byidField, update existing row or append a new one.delete— find byidField, remove the row.correlationKeymatches an in-flightIdempotency-Key→ reconcile + clearpending.versionskips by more than 1 → drop the model and re-fetch.
op and data shape are intentionally identical between the events you receive and the optimistic patches you supply, so adding new mutation types is mostly a backend concern.
ReactiveObject
Single-entity twin. Wraps a QQmlPropertyMap so QML accesses fields as plain properties.
ReactiveObject {
id: currentTodo
baseUrl: BackendConnection.url
token: BackendConnection.token
source: "/api/todos/" + selectedId // initial GET
topic: "app://model/todo/" + selectedId
}
Label { text: currentTodo.fields.title || "(loading)" }
CheckBox { checked: currentTodo.fields.done || false }
Properties
| Property | Type | Notes |
|---|---|---|
baseUrl / token / source / topic |
as above | But source is the entity URL (/api/todos/<id>) and topic is the entity topic (app://model/todo/<id>). |
fields |
QQmlPropertyMap |
Dynamic — fields appear under their JSON names. |
ready |
bool | Initial GET completed. |
pending |
bool | Optimistic mutation in flight. |
Methods
currentTodo.patch({ done: true })
currentTodo.remove()
Both behave like ReactiveListModel.invoke() — optimistic local mutation, HTTP request, Mercure reconciliation.
Choosing between them
- List view, table, grid —
ReactiveListModel. - Detail view of one entity —
ReactiveObject. Doesn't pull the entire collection over the wire. - Both at once (master/detail) — instantiate one of each. They use different Mercure topics, so a list update doesn't churn the detail subscription and vice-versa. The dual-publish is exactly to avoid this trade-off.
If your detail view shows fields that aren't in the list payload (e.g. a heavy description field), ReactiveObject's entity-topic subscription gets the full record on every change without affecting list-view subscribers.
Mercure dual-publish
Every #[BridgeResource] mutation publishes to two topics. From the PHP side this is invisible — ModelPublisher::publishUpdate($entity) dispatches both events with the same payload, the same correlationKey, and a shared incremented version.
postPersist | postUpdate | postRemove
│
▼
ModelPublisher::publishUpdate
│
├──▶ Mercure: app://model/<name> (collection event)
└──▶ Mercure: app://model/<name>/<id> (entity event)
Why both? A ReactiveListModel shouldn't process events about other resources, and a ReactiveObject shouldn't process events about other instances of its resource. Filtering at subscribe time is what Mercure topics are for; the cost is one extra publish per mutation.
Anti-patterns
- Driving the model from a custom mutation path that bypasses
invoke(). You losepending,correlationKeyreconciliation, and rollback. If your HTTP call doesn't fit theinvoke()shape, do the call manually but don't mutate the model directly — let the Mercure echo do it. - Reusing one
ReactiveListModelacross windows. Each instance owns its Mercure subscription; sharing means a closed window's subscription stays alive until the host exits. Spawn one per window; they auto-cohere via the server. - Sub-classing the model in QML to add fields. Don't — the model is dynamic. Add the field to the entity, regenerate the migration, and the field appears as a role automatically.
See also
- Update semantics — the wire-level rules these models implement.
- Makers — how
make:bridge:resourceproduces a starter<Name>List.qmlalready wired up. - QML API reference — exhaustive property/method list.