Files
php-qml/docs/reactive-models.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

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:

  1. Issues a GET <source> once BackendConnection.connectionState === Online.
  2. Subscribes to <topic> over Mercure SSE.
  3. Applies events to the local rows.
  4. Exposes a pending role 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:

  • pendingtrue while 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 by idField, update existing row or append a new one.
  • delete — find by idField, remove the row.
  • correlationKey matches an in-flight Idempotency-Key → reconcile + clear pending.
  • version skips 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, gridReactiveListModel.
  • Detail view of one entityReactiveObject. 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 lose pending, correlationKey reconciliation, and rollback. If your HTTP call doesn't fit the invoke() shape, do the call manually but don't mutate the model directly — let the Mercure echo do it.
  • Reusing one ReactiveListModel across 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:resource produces a starter <Name>List.qml already wired up.
  • QML API reference — exhaustive property/method list.