Files
php-qml/docs/architecture.md
magdev beb4e3ab9d docs: refresh README + docs/ for v0.2.0
The README still framed the project as "Phase 5 / pre-v0.1.0" and the
docs predated the v0.2.0 surface (typed BridgeOp, public service
interfaces, port negotiation, pre-migration auto-backup, bridge:export,
periodic auto-update, two new makers, qmltestrunner). Bring them in line
with what's actually shipped, and add badges (release, license, PHP,
Symfony, Qt, FrankenPHP, CI, platform) to the README so the status is
legible at a glance.

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

9.4 KiB
Raw Blame 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.

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.

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