4 Commits
HEAD ... dev

Author SHA1 Message Date
c673ec22e2 qml: defer ReactiveListModel/ReactiveObject initial fetch to componentComplete()
setBaseUrl() and setSource() used to fire refresh() inline as soon as
both `baseUrl` and `source` were populated — but setToken() never
triggered a refresh. QML evaluates literal property assignments before
bindings to other objects' properties, so a model declared with
literal `source` plus bindings to `BackendConnection.url` /
`BackendConnection.token` (the exact shape of make:bridge:window's
output) could fire its GET *before* the `token` binding had landed.
The unauthenticated request hit Symfony's SessionAuthenticator, came
back 401, and the model parked at `ready === false` with an empty
list. Mercure subscribed anonymously (the model explicitly clears the
SSE client's bearer), so subsequent server-side mutations propagated
fine — masking the initial-fetch failure as "list is empty until
something changes". Hit by the second window in examples/todo.

Both classes now implement QQmlParserStatus and trigger the initial
refresh from componentComplete(), where every binding (literal *and*
singleton-derived) is guaranteed to have landed. After completion,
individual setter changes still trigger refresh inline — so token
rotation / URL reassignment after first load behave unchanged.

Regression test under framework/qml/tests/tst_reactive_list_model.qml
using the v0.2.0 qmltestrunner harness. Adds a TestHttpServer helper
that mimics SessionAuthenticator's 401-on-no-bearer behaviour so the
regression is observable; verified the test fails against the unfixed
production code (`Actual: ""` vs `Expected: "Bearer testtoken"` on
the captured Authorization header).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 19:19:56 +02:00
a43b440b20 docs: refresh README status + roadmap, add 'What you get' section
The Status note still pointed at the dead Phase-4b/4c framing; the
Roadmap was a mix of legacy phase numbering and version numbers and
didn't reflect that macOS/Windows/Flathub/Snap have been consolidated
into a single v0.9.0 cross-platform packaging push (PLAN.md §13). Drop
phases entirely, list each shipped/upcoming SemVer version, and pull
v0.3.0 (i18n, persistent logs, cache warmup) and v0.9.0 forward so the
roadmap matches what's actually planned.

Add a 'What you get' section between 'What it is' and the 60-second
tour with concrete numbers (bundle size, cold start, idle RSS) and the
shipped capabilities (five makers, reactive models, supervisor
hardening, self-update, DX tooling, CI surface) so the README has more
substance than just an architecture description.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 18:35:54 +02:00
28af802e9c gitignore: ignore framework/qml/build-tests/
The make qmltest target writes its CMake build tree to build-tests/
(deliberately distinct from the regular build/ tree so a configured-with
-DBUILD_TESTING=ON tree doesn't shadow production builds). The existing
build/ patterns don't match it, so it kept showing up as untracked.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 22:29:49 +02:00
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
22 changed files with 826 additions and 77 deletions

2
.gitignore vendored
View File

@@ -1,6 +1,8 @@
# Build artefacts
build/
**/build/
build-tests/
**/build-tests/
# Composer
vendor/

View File

@@ -6,9 +6,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
## [Unreleased]
### Added
### Fixed
- (none yet — next changes land here)
- **`ReactiveListModel` / `ReactiveObject`: defer the initial fetch to `componentComplete()`.** Both classes now implement `QQmlParserStatus` and only fire the first `refresh()` from `componentComplete()` instead of inline from `setBaseUrl()` / `setSource()`. The pre-fix behaviour fired the GET as soon as the second of {`baseUrl`, `source`} was set — and because QML evaluates literal property assignments before bindings to other objects' properties, a model declared with literal `source` + bindings to `BackendConnection.url` / `BackendConnection.token` could fire its GET *before* the `token` binding had landed. The unauthenticated request hit Symfony's `SessionAuthenticator`, returned 401, and the model parked at `ready === false` with an empty list. Mercure subscribed anonymously (the model explicitly sets the SSE client's bearer to `""`), so subsequent server-side mutations propagated fine — masking the initial-fetch failure as "list is empty until something changes". Most visible when opening a second window via `make:bridge:window` after the first window's bindings had populated `BackendConnection`. After componentComplete, individual setter changes still trigger refresh inline as before, so token rotation / URL changes after first load behave unchanged. Regression test under [`framework/qml/tests/tst_reactive_list_model.qml`](framework/qml/tests/tst_reactive_list_model.qml) using the v0.2.0 `qmltestrunner` harness; added a `TestHttpServer` helper in the test scope that mimics `SessionAuthenticator`'s 401-on-no-bearer behaviour so the regression is observable as `ready === false` + empty `lastAuthHeader`.
## [0.2.0] — 2026-05-03

View File

@@ -2,7 +2,16 @@
A framework for native desktop applications with a **Symfony / FrankenPHP** backend and a **Qt / QML** frontend, packaged as a single distributable per OS.
> **Status:** Phase 5 / pre-v0.1.0. Phases 04a are merged (working framework, real POC, Linux AppImage, auto-update, release CI). macOS and Windows packaging are deferred to 4b/4c. See [CHANGELOG.md](CHANGELOG.md).
[![Release](https://img.shields.io/badge/release-v0.2.0-blue)](https://src.bundespruefstelle.ch/magdev/php-qml/releases/tag/v0.2.0)
[![License](https://img.shields.io/badge/license-LGPL--3.0--or--later-blue)](LICENSE)
[![PHP](https://img.shields.io/badge/PHP-8.4%2B-777bb4)](https://www.php.net/)
[![Symfony](https://img.shields.io/badge/Symfony-8-000000)](https://symfony.com/)
[![Qt](https://img.shields.io/badge/Qt-6.5%2B-41cd52)](https://www.qt.io/)
[![FrankenPHP](https://img.shields.io/badge/FrankenPHP-1.12%2B-ff7e1d)](https://frankenphp.dev/)
[![CI](https://src.bundespruefstelle.ch/magdev/php-qml/actions/workflows/ci.yml/badge.svg?branch=main)](https://src.bundespruefstelle.ch/magdev/php-qml/actions/workflows/ci.yml)
[![Platform](https://img.shields.io/badge/platform-Linux-yellow)](docs/packaging-linux.md)
> **Status:** v0.2.0 (2026-05-03). Linux AppImage is the only packaged target through the v0.2.0 / v0.3.0 minors; macOS, Windows, Flathub and Snap all land together in [v0.9.0](PLAN.md#v090--cross-platform-packaging-release-candidate-milestone) as a single cross-platform packaging push. Pre-v1.0 SemVer permits API breaks on minor bumps — see [CHANGELOG.md](CHANGELOG.md).
---
@@ -16,6 +25,16 @@ php-qml lets a PHP developer write a desktop app using ordinary Symfony on the b
It is **not** a PHP↔Qt language binding — the languages run in separate processes; the bridge is a wire protocol, not an FFI layer. That deliberately avoids the failure mode that left php-gtk and php-qt unmaintained.
## What you get
- **One ~150 MB AppImage** bundling Qt, the Symfony app, FrankenPHP, and the AppImageUpdate sidecar. Cold start ≤ 2 s on bare metal (≤ 4 s on shared CI runners), idle RSS ≤ 200 MB. Gates enforced in CI on every release tag.
- **Five `make:bridge:*` makers** covering CRUD, non-CRUD commands, domain events, query-only read-models, and second windows. The headline `make:bridge:resource` generates entity + REST controller + `ReactiveListModel`-bound QML in one command, with optional `--with-dto` for `#[MapRequestPayload]` + RFC 7807 validation.
- **Reactive models out of the box.** `ReactiveListModel` / `ReactiveObject` do the initial GET, subscribe to Mercure, apply optimistic mutations, and reconcile via `Idempotency-Key``correlationKey` round-tripping — no handwritten cross-side glue.
- **Production-grade bundled-mode supervisor.** Per-session bearer + JWT secrets (rotated on every restart), pre-migration SQLite auto-backup, runtime-negotiated TCP port (no two installed apps collide), `prctl(PR_SET_PDEATHSIG)` so a host crash takes the child with it.
- **Self-update** via embedded `zsync` (typical delta 1020 MB). Auto-checks on launch and every 6 h; the install step is always user-driven, never auto-restart.
- **Opt-in DX**: `bridge:doctor` readiness probe, `bridge:export` database backup, `DevConsole` QML in-window log viewer (Ctrl+backtick), single-instance lock with launch-arg forwarding (file-association friendly), shipped `.vscode/` + `.idea/` configs.
- **Quality gate on every push**: PHPStan + php-cs-fixer + PHPUnit + qmllint + `qmltestrunner` + an HTTP/SSE round-trip integration test + a bundled-supervisor smoke test + `perfsmoke` against the budgets above.
## 60-second tour
```bash
@@ -34,12 +53,14 @@ Add a reactive resource (entity + REST controller + QML snippet) with one maker:
```bash
cd my-app/symfony
bin/console make:bridge:resource Todo
bin/console make:bridge:resource Todo # add --with-dto for #[MapRequestPayload] + RFC 7807 errors
bin/console make:migration && bin/console doctrine:migrations:migrate -n
```
`make dev` opens the Qt window, connection state flips to **Online**, and the generated `TodoList.qml` shows a list whose `ReactiveListModel` is auto-subscribed to `app://model/todo` over Mercure. There is no handwritten cross-side glue.
The maker family covers the four common shapes: [`make:bridge:resource`](docs/makers.md#makebridgeresource) (CRUD), [`make:bridge:command`](docs/makers.md#makebridgecommand) (non-CRUD action), [`make:bridge:event`](docs/makers.md#makebridgeevent) (domain event → QML signal), [`make:bridge:read-model`](docs/makers.md#makebridgeread-model) (query-only projection), and [`make:bridge:window`](docs/makers.md#makebridgewindow) (second window).
For a non-trivial app with a multi-window test, crash-recovery test, and AppImage packaging, see [`examples/todo/`](examples/todo/README.md).
## Documentation
@@ -50,12 +71,13 @@ The full developer documentation lives under [`docs/`](docs/README.md):
- **[Architecture](docs/architecture.md)** — process pair, transport, dev vs bundled mode.
- **[Update semantics](docs/update-semantics.md)** — connection state machine, optimistic mutations, idempotency.
- **[Reactive models](docs/reactive-models.md)** — `ReactiveListModel`, `ReactiveObject`, Mercure dual-publish.
- **[Makers](docs/makers.md)** — `make:bridge:resource` / `command` / `window`.
- **[Dev workflow](docs/dev-workflow.md)** — hot reload, dev console, editor setup, `bridge:doctor`.
- **[Bundled mode](docs/bundled-mode.md)** — supervisor, per-session secret rotation, first-launch migrations.
- **[Linux packaging](docs/packaging-linux.md)** — `make appimage`, auto-update, performance budgets.
- **[Makers](docs/makers.md)** — `make:bridge:resource` / `command` / `event` / `read-model` / `window`.
- **[Dev workflow](docs/dev-workflow.md)** — hot reload, dev console, editor setup, `bridge:doctor`, `make qmltest`.
- **[Bundled mode](docs/bundled-mode.md)** — supervisor, per-session secret rotation, port negotiation, pre-migration auto-backup, first-launch migrations.
- **[Linux packaging](docs/packaging-linux.md)** — `make appimage`, auto-update (launch + 6h poll), performance budgets.
- **[Native dialogs](docs/native-dialogs.md)** — file pickers, message boxes, system tray; the QML/PHP boundary.
- **[Configuration reference](docs/configuration.md)** — env vars, CLI flags.
- **[QML API reference](docs/qml-api.md)** / **[PHP API reference](docs/php-api.md)** — singletons, components, attributes, services.
- **[QML API reference](docs/qml-api.md)** / **[PHP API reference](docs/php-api.md)** — singletons, components, attributes, services, interfaces.
Design rationale and roadmap live in [PLAN.md](PLAN.md). User-facing changes per release are in [CHANGELOG.md](CHANGELOG.md).
@@ -65,13 +87,24 @@ PHP 8.4+ · Symfony 8 · Doctrine ORM 3 · FrankenPHP 1.12+ (worker mode) · Mer
## Roadmap
- **Phase 0** ✅ throwaway transport spike.
- **Phase 1** ✅ framework skeleton, dev mode, single-instance lock, CI quality gate.
- **Phase 2** ✅ reactive models, update semantics, headline maker.
- **Phase 3** ✅ POC todo app, integration + snapshot tests.
- **Phase 4a** ✅ bundled mode, Linux AppImage, release CI, AppImageUpdate.
- **Phase 4b/4c** ⏳ macOS / Windows packaging.
- **Phase 5** 🚧 DX polish — dev console, init script, hot-reload docs, v0.1.0 prep.
The original Phase 05 POC roadmap shipped as v0.1.0 on 2026-05-03. From there on, work is organised by SemVer version (see [PLAN.md §13](PLAN.md#13-versions) for the full per-version breakdown).
- **v0.1.0** ✅ first public preview — process-pair architecture, reactive models, three headline makers, bundled mode, Linux AppImage, AppImageUpdate, release CI, DX polish (dev console, `php-qml-init`, editor configs).
- **v0.1.1** ✅ shakedown follow-ups — `/healthz` deep-load canary, bundled-supervisor integration test, skeleton AppImage parity, cache-wipe on bundled launch.
- **v0.1.2** ✅ post-shakedown audit — clean child shutdown via `aboutToQuit`, configurable `bridge.qml_path`, `SessionAuthenticator` problem+json on the entry-point path, `CorrelationKeyListener` sub-request guard.
- **v0.2.0** ✅ public-API surface (`BridgeOp` enum, `PublisherInterface` / `ModelPublisherInterface` / `CorrelationContextInterface`, `BridgeBundleInfo`), port negotiation, pre-migration auto-backup, `bridge:export`, periodic auto-update check, `make:bridge:event` + `make:bridge:read-model` makers, `--with-dto` opt-in, `qmltestrunner` in CI.
- **v0.3.0** ⏳ later minor — i18n bridge (Symfony Translator + Qt Translator with shared locale switch), persistent log files + rotation, build-time Symfony cache warmup (requires `kernel.project_dir` virtualisation, hence its own minor).
- **v0.9.0** ⏳ cross-platform packaging release-candidate milestone — macOS (`.app` + Sparkle 2 + notarisation), Windows (NSIS + WinSparkle + Authenticode), Flathub + Snap, multi-arch (Linux ARM64, Windows ARM, macOS universal), composer `create-project php-qml/skeleton`, opt-in telemetry + crash reporting. Held until one push because the cert / runner / notarisation prerequisites overlap.
- **v1.0.0** ⏳ API stabilisation — auth model finalised, AppImage relinkability documented end-to-end, security model audited. Pre-1.0 minor bumps may still break public API.
## Tested platforms
| OS | Packaging | CI |
| --------------- | --------- | -------------------------- |
| Linux x86_64 | AppImage | Gitea Actions (every push) |
| macOS / Windows | v0.9.0 | — |
Performance gates (`tests/perfsmoke.sh`) enforced on every release tag: bundle ≤ 200 MB, cold start ≤ 2 s (4 s on shared CI), idle RSS ≤ 200 MB. See [docs/packaging-linux.md §performance smoke](docs/packaging-linux.md#performance-smoke).
## Contributing
@@ -79,15 +112,14 @@ Active development happens on the `dev` branch; `main` only carries release comm
```bash
cd framework/php && composer quality # PHPStan + cs-fixer + PHPUnit
cd framework/skeleton && make qmltest # qmltestrunner unit tests (Quick Test)
cd examples/todo && make quality # adds qmllint + integration test
```
A dedicated `CONTRIBUTING.md` arrives with Phase 5's wrap-up.
## Versioning
[Semantic Versioning](https://semver.org/) — `MAJOR.MINOR.BUGFIX`. Pre-v1.0.0, minor bumps may break public API.
[Semantic Versioning](https://semver.org/) — `MAJOR.MINOR.BUGFIX`. Pre-v1.0.0, minor bumps may break public API; bugfix bumps don't.
## License
To be decided before v0.1.0 is tagged. The framework's own code will be permissively licensed; Qt is shipped under LGPL with relinkability obligations — see [PLAN.md §12](PLAN.md#12-open-questions-and-risks).
[**LGPL-3.0-or-later**](LICENSE) — chosen to align with Qt 6's LGPLv3 licensing. The bundled AppImage honours the relinkability obligations (Qt libs are shipped as separate `.so`s, not statically linked); see [PLAN.md §12](PLAN.md#12-open-questions-and-risks) for the full rationale.

View File

@@ -15,16 +15,16 @@ Developer documentation for [php-qml](../README.md). Design rationale and roadma
## Guides
- **[Makers](makers.md)** — `make:bridge:resource`, `make:bridge:command`, `make:bridge:window`.
- **[Dev workflow](dev-workflow.md)** — hot reload (PHP + QML), dev console (`Ctrl+\``), editor configs, `bridge:doctor`.
- **[Linux packaging](packaging-linux.md)** — `make appimage`, AppImageUpdate, performance budgets.
- **[Makers](makers.md)** — `make:bridge:resource` (`--with-dto` opt-in), `make:bridge:command`, `make:bridge:event`, `make:bridge:read-model`, `make:bridge:window`.
- **[Dev workflow](dev-workflow.md)** — hot reload (PHP + QML), dev console (Ctrl+backtick), editor configs, `bridge:doctor`, `make qmltest`.
- **[Linux packaging](packaging-linux.md)** — `make appimage`, AppImageUpdate (launch + periodic), performance budgets.
- **[Native dialogs](native-dialogs.md)** — file pickers, confirmations, system notifications: where they live (QML, not PHP) and how to use the platform-native components Qt already ships.
## Reference
- **[QML API](qml-api.md)** — `BackendConnection`, `RestClient`, `MercureClient`, `ReactiveListModel`, `ReactiveObject`, `AppShell`, `DevConsole`, `SingleInstance`.
- **[PHP API](php-api.md)** — `BridgeBundle`, `#[BridgeResource]`, `ModelPublisher`, `bridge:doctor`, `CorrelationKeyListener`, `SessionAuthenticator`.
- **[Configuration](configuration.md)** — env vars (`BRIDGE_URL`, `BRIDGE_TOKEN`, `FRANKENPHP`, …) and CLI flags for `php-qml-init` / `build-appimage.sh`.
- **[PHP API](php-api.md)** — `BridgeBundle`, `#[BridgeResource]`, `BridgeOp` enum, `PublisherInterface`, `ModelPublisherInterface`, `CorrelationContextInterface`, `BridgeBundleInfo`, `bridge:doctor`, `bridge:export`, `SessionAuthenticator`.
- **[Configuration](configuration.md)** — env vars (`BRIDGE_URL`, `BRIDGE_TOKEN`, `BRIDGE_PORT`, `BRIDGE_AUTO_UPDATE_*`, `FRANKENPHP`, …) and CLI flags for `php-qml-init` / `build-appimage.sh`.
## How the docs are organised

View File

@@ -19,7 +19,7 @@ A running app is two processes:
- 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.
- 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

View File

@@ -19,6 +19,22 @@ if (!explicitUrl.isEmpty()) {
Bundled-mode init runs after the QApplication event loop is up so `QStandardPaths` and `QProcess` work correctly.
## Port negotiation
Bundled mode does not hardcode a TCP port. On every spawn the host:
1. Binds a `QTcpServer` to `QHostAddress::LocalHost` port 0 (kernel picks a free ephemeral port).
2. Captures `serverPort()`, then closes the probe socket.
3. Hands the chosen port to FrankenPHP via the `PORT` env var.
The bundled `Caddyfile` reads `{$PORT:8765}`, so it picks up whatever the host negotiated and falls back to `8765` only when the env is unset (i.e. dev mode without an override).
The chosen port is also written to `~/.local/share/<app>/var/bridge.port` on every launch. External tools (a debug helper, a `curl /healthz` from a script) can read the file instead of grepping Qt's log for the address.
For reproducible test harnesses, pin the port via the `BRIDGE_PORT=<n>` env var. Both `examples/todo/tests/bundled-supervisor.sh` and `tests/perfsmoke.sh` set this so multiple harnesses can run side by side without contending. When `BRIDGE_PORT` is set the negotiation step is skipped.
Why negotiate at all? Two installed php-qml apps used to race for `8765` on first launch — whichever lost went Offline. Negotiation eliminates the collision class; the kernel guarantees uniqueness within the host.
## Resolving the FrankenPHP child
Bundled mode needs three things on disk near the host binary:
@@ -86,6 +102,20 @@ env.insert("DATABASE_URL", databaseUrl()); // sqlite:///<userdata>/var/data.sq
If migrations fail or time out, bundled mode goes Offline before spawning. Apps that want to handle a corrupt DB gracefully can detect this via `BackendConnection.error` and show a "reset database?" UI.
### Pre-migration auto-backup
Before invoking `doctrine:migrations:migrate`, the supervisor copies `var/data.sqlite` to `var/data.sqlite.<unix-timestamp>.bak` and keeps the 5 most recent backups. SQLite has no transactional DDL — a half-applied migration can corrupt the database with no rollback path. The backup is cheap insurance against that.
- Skipped on first launch (no DB exists yet).
- Failure to copy logs a warning and continues — a missing safety net is not a reason to refuse to boot.
- Bundled mode only. Dev-mode users own `symfony/var/data.sqlite` themselves.
If migration corrupts the DB, the user's recovery is `cp var/data.sqlite.<latest>.bak var/data.sqlite` from the data directory; the next launch boots against the rolled-back schema (and a future migration retry succeeds against the original state).
### Database export
Apps can offer a "Save a copy of my data" button by calling [`BackendConnection.exportDatabase(path)`](qml-api.md#exportdatabase) (Q_INVOKABLE) — typically paired with `Qt.labs.platform.FileDialog`. The same operation is available as `bin/console bridge:export <destination>` for CLI use. Both read the source path from `DATABASE_URL` so they work in dev mode and bundled mode unchanged.
## Supervisor
The supervisor is `BackendConnection::onChildFinished()` plus a retry counter:
@@ -124,7 +154,7 @@ m_child->setChildProcessModifier([] {
});
```
Without this, a host crash leaves an orphan FrankenPHP process holding port 8765, and the *next* launch can't bind.
Without this, a host crash leaves an orphan FrankenPHP process holding the negotiated port (and consuming the user's data files); the *next* launch finds no parent to connect back to but the orphan still races for resources.
`PR_SET_PDEATHSIG` only works on Linux. macOS and Windows builds will use platform-equivalents in their respective phases (see PLAN.md §4b/§4c).
@@ -134,7 +164,7 @@ Same as dev mode: `GET /healthz` every 5 s, 2 s timeout, 30 s threshold for Offl
## Auto-update
Bundled mode also wires up the AppImageUpdate sidecar — see [Linux packaging §auto-update](packaging-linux.md#auto-update). Three QML signals carry the result:
Bundled mode wires up the AppImageUpdate sidecar — see [Linux packaging §auto-update](packaging-linux.md#auto-update). Three QML signals carry the result:
```qml
Connections {
@@ -150,6 +180,17 @@ Button { text: "Update"; onClicked: BackendConnection.applyUpdate() }
Both methods are no-ops in dev mode — they emit `updateCheckFailed("update checks are bundled-mode only")` so QML can treat them uniformly.
### Periodic check
The supervisor arms an automatic poll on the first `Online` transition: a launch-time check 10 s after the backend is ready, then a recurring check every 6 hours. PLAN.md §11 *Auto-update* asked for "check on launch and once per N hours; offer install on next restart, never auto-restart" — the poll surfaces `updatesAvailable()` so apps can show a banner; `applyUpdate()` is still the explicit install trigger and there is no auto-restart.
| Env var | Default | Effect |
| --- | --- | --- |
| `BRIDGE_AUTO_UPDATE_DISABLE` | unset | Set to `1` to disable the periodic poll. The Q_INVOKABLE `checkForUpdates()` / `applyUpdate()` still work. |
| `BRIDGE_AUTO_UPDATE_PERIOD_MIN` | `360` (6 h) | Override the period in minutes. |
Dev mode skips the periodic check entirely.
## Single-instance lock
The host uses `SingleInstance` (a QLocalServer-backed lock) regardless of mode:

View File

@@ -10,10 +10,13 @@ Exhaustive lookup for env vars and CLI flags. For *what* the framework does with
| --- | --- | --- |
| `BRIDGE_URL` | unset | If set, host runs in [dev mode](architecture.md#dev-mode-vs-bundled-mode) and connects to this URL. If unset, host runs in [bundled mode](bundled-mode.md). |
| `BRIDGE_TOKEN` | unset | Bearer token for `Authorization` headers. Dev mode reads this from env (typically set in `.env`); bundled mode generates a per-session value and ignores env. |
| `BRIDGE_PORT` | (negotiated) | Bundled mode: pin a specific TCP port instead of negotiating one. Set by test harnesses (`bundled-supervisor.sh`, `perfsmoke.sh`) for reproducibility. Dev mode ignores it (the Caddyfile still reads `{$PORT:8765}`). |
| `BRIDGE_FRANKENPHP_BIN` | `<bin>/bin/frankenphp` | Bundled mode: override the FrankenPHP binary path. |
| `BRIDGE_SYMFONY_DIR` | candidate list | Bundled mode: override the Symfony app directory. Candidates: `<bin>/symfony`, `<bin>/../symfony`, `<bin>/../share/<app>/symfony`, `<bin>/../usr/share/<app>/symfony`. |
| `BRIDGE_CADDYFILE` | candidate list | Bundled mode: override the Caddyfile path. Same candidate prefixes as `BRIDGE_SYMFONY_DIR`. |
| `BRIDGE_APPIMAGEUPDATE_BIN` | `<bin>/AppImageUpdate.AppImage` | Override the auto-update sidecar path. |
| `BRIDGE_AUTO_UPDATE_DISABLE` | unset | Bundled mode: set to `1` to disable the periodic auto-update poll. The QML `checkForUpdates()` / `applyUpdate()` Q_INVOKABLEs still work. |
| `BRIDGE_AUTO_UPDATE_PERIOD_MIN` | `360` | Bundled mode: override the periodic auto-update interval in minutes (default 6 h). |
| `APPIMAGE` | set by AppImage runtime | Bundled-mode auto-update reads this to know which AppImage to update. |
### Read by the bundled Symfony app
@@ -32,7 +35,7 @@ These come from `framework/skeleton/symfony/.env` in dev mode and from environme
| `MERCURE_JWT_SECRET` | HMAC secret for minting publisher JWTs. ≥256 bits. |
| `MERCURE_PUBLISHER_JWT_KEY` | Same value as `MERCURE_JWT_SECRET`. |
| `MERCURE_SUBSCRIBER_JWT_KEY` | Same value as `MERCURE_JWT_SECRET`. |
| `PORT` | `8765`. Used by the Caddyfile. Override for non-default ports. |
| `PORT` | `8765` in dev. In bundled mode the host sets this to the negotiated port (or to `BRIDGE_PORT` if pinned). The Caddyfile reads `{$PORT:8765}`. |
### Read by `make dev` / `scripts/dev.sh`
@@ -85,6 +88,20 @@ php-qml-init [--framework <dir>] [--vendor] [--skip-install] [--git] <name>
The script auto-validates that `<name>` doesn't already exist (or that the existing dir is empty). It rewrites every `skeleton` identifier to the project name (CMake project, Qt target, QML URI, app title, single-instance lock id, composer path-repo, CMake `add_subdirectory(framework/qml)`, `.vscode/launch.json`).
### `bin/console bridge:export`
```
bin/console bridge:export <destination>
```
Copies the active SQLite database (read from `DATABASE_URL`) to `<destination>`. Overwrites the destination if it exists. Works in both dev and bundled mode.
| Arg | Required | Notes |
| --- | --- | --- |
| `<destination>` | yes | Filesystem path. The QML side has the same operation as `BackendConnection.exportDatabase(path)`. |
Errors with exit code `1` if `DATABASE_URL` doesn't point at a SQLite file or if the source doesn't exist. See [PHP API §bridge:export](php-api.md#bridgeexport).
### `packaging/linux/build-appimage.sh`
```
@@ -114,9 +131,11 @@ Run from a project's root (skeleton, todo example, or a `php-qml-init`'d project
| `make dev` | `make build` + `scripts/dev.sh` (FrankenPHP `--watch` + Qt host). |
| `make doctor` | `bin/console bridge:doctor`. |
| `make doctor-connect` | `bin/console bridge:doctor --connect`. |
| `make quality` | PHP quality + qmllint + (todo example) integration test. |
| `make qmltest` | Configure with `-DBUILD_TESTING=ON`, run the `qmltestrunner` Quick Test target via CTest. Skeleton + `examples/todo`. |
| `make quality` | PHP quality + qmllint + `qmltest` + (todo example) integration test. |
| `make integration` | (todo example only) HTTP+SSE round-trip + crash-recover smoke. |
| `make appimage` | (todo example only) Stage symfony --no-dev, run `build-appimage.sh`. |
| `make integration-bundled` | (todo example only) bundled-mode supervisor smoke (cache redirect + auto-backup + clean shutdown). |
| `make appimage` | Stage symfony --no-dev, run `build-appimage.sh`. (Skeleton + todo example.) |
| `make perf` | (todo example only) Run `tests/perfsmoke.sh` against the built AppImage. |
| `make clean` | Remove `build/`. |
@@ -126,11 +145,13 @@ Run from a project's root (skeleton, todo example, or a `php-qml-init`'d project
| What | Where |
| --- | --- |
| FrankenPHP HTTP / Mercure | `http://127.0.0.1:8765` |
| FrankenPHP HTTP / Mercure | `http://127.0.0.1:<port>` (`8765` in dev; negotiated in bundled mode) |
| Mercure SSE endpoint | `/.well-known/mercure` |
| Health probe | `GET /healthz` (returns `200 OK` when ready) |
| Health probe | `GET /healthz` (returns `200 OK` when ready; response carries `name`, `bundle`) |
| Bundled-mode user data | `~/.local/share/<app>/var/` (Linux). `XDG_DATA_HOME` honoured. |
| Bundled-mode SQLite | `~/.local/share/<app>/var/data.sqlite` |
| Bundled-mode auto-backups | `~/.local/share/<app>/var/data.sqlite.<timestamp>.bak` (last 5 kept) |
| Bundled-mode runtime port | `~/.local/share/<app>/var/bridge.port` (written every launch) |
| Bundled-mode logs | `~/.local/share/<app>/var/log/` |
| Single-instance socket | `~/.local/share/<app>/<app>.sock` |
| AppImage AppDir layout | `usr/bin/<app>`, `usr/share/<app>/symfony/`, `usr/share/<app>/Caddyfile`, `usr/bin/AppImageUpdate.AppImage` |

View File

@@ -165,6 +165,25 @@ If you intentionally changed the template, regenerate the snapshot and commit it
git add tests/snapshot/
```
## QML unit tests (`make qmltest`)
`framework/qml/tests/` ships a [Qt Quick Test](https://doc.qt.io/qt-6/qtquicktest-index.html) executable target (`qml_unit_tests`) discovered by CTest. Built only when CMake is configured with `-DBUILD_TESTING=ON`, so production AppImages don't carry it.
Locally:
```bash
cd framework/skeleton # or examples/todo / a php-qml-init'd project
make qmltest
# → cmake -DBUILD_TESTING=ON -S qml -B build/qml
# → cmake --build build/qml --target qml_unit_tests
# → ctest --test-dir build/qml --output-on-failure
# ✓ tst_smoke.qml passed
```
Add per-feature tests next to `tst_smoke.qml` as `tst_<feature>.qml` — Quick Test auto-discovers them. Tests run under the `offscreen` Qt platform plugin so CI doesn't need `xvfb`.
This is wired into `make quality` (skeleton + todo example) and into the Gitea Actions `Quality` job after qmllint, so QML regressions fail the build alongside PHP regressions.
## Integration test loop
`examples/todo/tests/integration.sh` boots the example app in dev mode, fires a real HTTP+SSE round-trip plus a crash-recover, and asserts the output. Run it after touching anything in `BackendConnection`, `MercureClient`, or `ReactiveListModel`:

View File

@@ -233,7 +233,7 @@ curl -i http://127.0.0.1:8765/healthz
- `Connection refused` — FrankenPHP didn't start. Check `tail -f symfony/var/log/dev.log` and the terminal `make dev` is running in.
- `401` on `/api/*` — the host is sending the wrong bearer. In dev that's `BRIDGE_TOKEN` from `.env`; the Qt host reads it via `BackendConnection.token` which defaults to `qgetenv("BRIDGE_TOKEN")`.
- Port 8765 already taken — another `make dev` is still running. `pkill -f frankenphp` and retry.
- Port 8765 already taken — another `make dev` is still running. `pkill -f frankenphp` and retry. (Bundled-mode AppImages don't share this failure mode — they negotiate a free ephemeral port at launch; see [Bundled mode §port negotiation](bundled-mode.md#port-negotiation).)
### `composer install` fails with "your php version (8.3.x) does not satisfy"

View File

@@ -1,14 +1,16 @@
# Makers
php-qml ships three [`symfony/maker-bundle`](https://symfony.com/bundles/SymfonyMakerBundle/current/index.html) makers. They generate the cross-side wiring (entity + controller + QML) for the common shapes — that's how a non-trivial app's PHP↔QML glue stays effectively zero.
php-qml ships five [`symfony/maker-bundle`](https://symfony.com/bundles/SymfonyMakerBundle/current/index.html) makers. They generate the cross-side wiring (entity / controller / event / read-model / second window) for the common shapes — that's how a non-trivial app's PHP↔QML glue stays effectively zero.
All three are invoked from `symfony/`:
All of them are invoked from `symfony/`:
```bash
cd symfony
bin/console make:bridge:resource <Name>
bin/console make:bridge:command <Name>
bin/console make:bridge:window <Name>
bin/console make:bridge:resource <Name> # CRUD: entity + controller + ReactiveListModel
bin/console make:bridge:command <Name> # non-CRUD action endpoint
bin/console make:bridge:event <Name> # domain event → Mercure → typed QML signal
bin/console make:bridge:read-model <Name> # query-only projection (no Mercure)
bin/console make:bridge:window <Name> # second-window QML scaffold
```
The maker bundle is `require-dev`, so production AppImage builds (which use `composer install --no-dev`) intentionally skip it. Run makers in development; check the output into git.
@@ -65,6 +67,35 @@ When to use which:
- **UUIDv7 (default)** — distributed-friendly, exposes no insertion-order leak, sortable. Good for anything an end-user might sync between machines.
- **Integer ID** — easier to debug from a shell; smaller wire size; no client-side ID generation needed. Pick this for purely-local single-machine apps.
### `--with-dto` — typed payloads + RFC 7807 errors
Pass `--with-dto` to opt the controller into Symfony's `#[MapRequestPayload]` resolver:
```bash
bin/console make:bridge:resource Todo --with-dto
# created: src/Entity/Todo.php
# created: src/Dto/CreateTodoDto.php
# created: src/Dto/UpdateTodoDto.php
# created: src/Controller/TodoController.php
# created: ../qml/TodoList.qml
```
The generated controller dispatches via the DTOs:
```php
public function create(#[MapRequestPayload] CreateTodoDto $dto): JsonResponse { /* … */ }
public function update(Todo $todo, #[MapRequestPayload] UpdateTodoDto $dto): JsonResponse { /* … */ }
```
What you get for free:
- **Malformed JSON** → 400 `application/problem+json`. No `if (!is_array($data))` boilerplate.
- **Missing required fields / `#[Assert\NotBlank]` violations** → 422 `application/problem+json` with field-by-field detail. `RestClient` parses the response into the `commandFailed` rejection's `problem` arg automatically.
- **No silent type coercion** — `done: "yes"` rejects instead of being cast to true.
- **PATCH semantics** — `Update<Name>Dto` fields default to nullable so callers send only what changed.
Without `--with-dto` the controller still ships and works — the DTO opt-in is for apps that want the RFC 7807 contract end-to-end. The maker fails loud if `symfony/validator` isn't autoloadable; the skeleton + `examples/todo` already require it.
#### `src/Controller/<Name>Controller.php`
CRUD endpoints on `/api/<lowercase-name>`:
@@ -170,6 +201,70 @@ CRUD covers the 80%. Reach for a command when:
- The action affects multiple resources atomically (e.g. *mark all done* across a collection).
- You need request-side validation that doesn't fit the entity (e.g. *only the owner can do this*).
## `make:bridge:event`
Generates a domain-event class, an event subscriber that republishes via `PublisherInterface` on `app://event/<kebab-name>`, and a QML stub that re-emits the wire payload as a typed signal.
```bash
bin/console make:bridge:event ImportFinished
# created: src/Event/ImportFinishedEvent.php
# created: src/EventSubscriber/ImportFinishedSubscriber.php
# created: ../qml/ImportFinishedEventHandler.qml
```
The generated event is a readonly value object — fields are arguments to `__construct`, exposed as readonly properties. The subscriber listens for the event, normalises it to JSON, and publishes through the bundle's `PublisherInterface`. The QML stub instantiates a `MercureClient` on the topic and re-emits the parsed payload as a typed `signal`:
```qml
ImportFinishedEventHandler {
onTriggered: function(payload) {
tray.showMessage("Import finished", `${payload.rowCount} rows`)
}
}
```
Use it from your code by dispatching the event:
```php
public function __invoke(EventDispatcherInterface $dispatcher): void
{
// … import work …
$dispatcher->dispatch(new ImportFinishedEvent(rowCount: $count));
}
```
### When to reach for an event vs a `BridgeResource`
- **Resource changed** (an entity was created / updated / deleted) → `#[BridgeResource]` does the dual-publish for you.
- **Something happened that isn't a resource state change** (background job done, push notification, validation outcome) → `make:bridge:event`. The QML side gets a typed signal instead of trying to derive intent from state diffs.
The split keeps the *what changed* (resource topics) separate from the *what happened* (event topics) so QML subscribers don't have to filter.
## `make:bridge:read-model`
Generates a query-only projection: a query service, a single GET controller, and a `ReactiveListModel`-bound QML stub — deliberately *without* a Mercure topic.
```bash
bin/console make:bridge:read-model OverdueTodos
# created: src/ReadModel/OverdueTodosReadModel.php
# created: src/Controller/OverdueTodosController.php
# created: ../qml/OverdueTodosList.qml
```
| File | Purpose |
| --- | --- |
| `src/ReadModel/<Name>ReadModel.php` | Query service stub. Inject `EntityManagerInterface`; return DTOs/arrays. |
| `src/Controller/<Name>Controller.php` | `GET /api/<kebab-plural>` handler. Forwards to the read-model service. |
| `qml/<Name>List.qml` | `ReactiveListModel` bound to the route. **No `topic`** — read-models aren't auto-reactive. |
Read-models intentionally don't subscribe to a Mercure topic. They're rebuilt on demand (or on a Refresh button) and invalidated by *events*, not by raw entity persistence. To trigger a refresh from the server side, pair this maker with `make:bridge:event` — the QML stub can listen for the event signal and call `model.refresh()`.
### When to use a read-model vs a resource
- **The QML view shows the entity itself** (a row per record, fields map 1:1) → `make:bridge:resource`.
- **The QML view shows a derived projection** (joined tables, aggregates, filtered subsets, denormalised reports) → `make:bridge:read-model`. The query lives in PHP; the QML side just renders.
Read-models are the answer to "I tagged the entity with `#[BridgeResource]` but the list view needs a JOIN" — that's a different shape and shouldn't be force-fit into the dual-publish.
## `make:bridge:window`
Generates a second-window scaffold. Useful when an app is fundamentally multi-window (settings dialog, detail pop-out, second viewport).

View File

@@ -117,6 +117,17 @@ void BackendConnection::applyUpdate() {
`appImage` is `qgetenv("APPIMAGE")` — the AppImage runtime exports this when an AppImage launches. Outside an AppImage the env is unset and both methods short-circuit with `updateCheckFailed("APPIMAGE env not set; not running from a packaged AppImage")`.
### Periodic check
The supervisor schedules `checkForUpdates()` automatically on the first `Online` transition (10 s after backend ready) and re-arms it every 6 hours by default. PLAN.md §11 *Auto-update* asked for "check on launch and once per N hours; offer install on next restart, never auto-restart" — the periodic check surfaces `updatesAvailable()` for an in-app banner; `applyUpdate()` is still the explicit user-driven trigger and there is no auto-restart.
Two env vars tune it (see [Bundled mode §periodic check](bundled-mode.md#periodic-check)):
- `BRIDGE_AUTO_UPDATE_DISABLE=1` — skip the periodic poll (Q_INVOKABLE methods still work).
- `BRIDGE_AUTO_UPDATE_PERIOD_MIN=<minutes>` — override the default 360 minutes.
Dev mode skips the periodic check entirely.
### Appcast (`latest.json`)
CI publishes a `latest.json` next to the release artefacts:

View File

@@ -15,9 +15,13 @@ return [
| Symbol | Kind | Use it when… |
| --- | --- | --- |
| [`#[BridgeResource]`](#bridgeresource-attribute) | PHP attribute | You want a Doctrine entity to dual-publish on Mercure automatically. |
| [`BridgeOp`](#bridgeop-enum) | Enum | You're calling `ModelPublisher::publishEntityChange` directly. |
| [`PublisherInterface`](#publisherinterface) / [`ModelPublisherInterface`](#modelpublisherinterface) / [`CorrelationContextInterface`](#correlationcontextinterface) | Interfaces | You're typehinting bridge services in your own controllers / listeners. |
| [`ModelPublisher`](#modelpublisher) | Service | You want to publish a custom event without persist/update/remove. |
| [`CorrelationContext`](#correlationcontext) | Service | You're inside a non-controller code path and need the current request's `Idempotency-Key`. |
| [`BridgeBundleInfo`](#bridgebundleinfo) | Value object | You want a deep-load canary on the bundle (e.g. a custom `/healthz`). |
| [`bridge:doctor`](#bridgedoctor) | Console command | You want to verify the dev environment is wired correctly. |
| [`bridge:export`](#bridgeexport) | Console command | You want to copy the active SQLite database to a user-chosen path. |
| [`SessionAuthenticator`](#sessionauthenticator) | Security authenticator | (Internal) checks the `Authorization: Bearer` header against `BRIDGE_TOKEN`. |
| [`CorrelationKeyListener`](#correlationkeylistener) | Event subscriber | (Internal) reads `Idempotency-Key` into `CorrelationContext`. |
@@ -56,6 +60,26 @@ After tagging, every `postPersist` / `postUpdate` / `postRemove` event triggers
The maker (`make:bridge:resource`) attaches this attribute automatically.
### `BridgeOp` enum
Wire-format enum for `op` field on every Mercure event the bundle publishes:
```php
namespace PhpQml\Bridge;
enum BridgeOp: string
{
case Upsert = 'upsert';
case Delete = 'delete';
case Replace = 'replace';
case Event = 'event';
}
```
The string values are the on-the-wire format — QML clients hardcode them, so renaming a case (without changing its `value`) is safe; changing a `value` is a wire-protocol break (and `BridgeOpTest` will fail the build before it ships).
You only deal with this directly when calling `ModelPublisher::publishEntityChange` from a custom code path. The Doctrine subscriber, the makers, and the `#[BridgeResource]` plumbing pick the right case for you.
### Custom resource name
```php
@@ -73,20 +97,67 @@ Topics become `app://model/task` and `app://model/task/<id>`. Useful when the st
---
## `ModelPublisher`
## `PublisherInterface`
Service that does the dual-publish. Auto-fired by the bundle's Doctrine subscriber on `postPersist` / `postUpdate` / `postRemove` for any `#[BridgeResource]` entity. Inject it directly when you want to publish a *custom* event (e.g. progress on a long-running command).
Thin Mercure facade. Concrete implementation: `Publisher` (autowired). Typehint the interface in app code so a swappable implementation (e.g. an offline-buffer publisher that queues events when Mercure is unreachable) stays non-breaking.
```php
namespace PhpQml\Bridge;
final class ModelPublisher
interface PublisherInterface
{
public function publishEntityChange(object $entity, string $op): void;
public function publish(string $topic, array $data): void;
}
```
`$op` is one of `"upsert"` / `"delete"`. The published JSON payload:
Mirrors upstream Symfony's `HubInterface`/`Hub` split. Existing call sites that typehint the concrete `Publisher` class keep working — autowire continues to inject the concrete implementation transparently.
## `ModelPublisherInterface`
The dual-publish surface, one level up from `PublisherInterface`. Concrete implementation: `ModelPublisher`.
```php
namespace PhpQml\Bridge;
interface ModelPublisherInterface
{
public function publishEntityChange(object $entity, BridgeOp $op): void;
}
```
`DoctrineBridgeListener` typehints this interface, not the concrete class.
## `CorrelationContextInterface`
Request-scoped key holder; concrete implementation: `CorrelationContext`. See [`CorrelationContext`](#correlationcontext) for the contract.
```php
namespace PhpQml\Bridge;
interface CorrelationContextInterface
{
public function set(?string $key): void;
public function get(): ?string;
public function clear(): void;
}
```
## `ModelPublisher`
Service that does the dual-publish. Auto-fired by the bundle's Doctrine subscriber on `postPersist` / `postUpdate` / `postRemove` for any `#[BridgeResource]` entity. Inject it (or `ModelPublisherInterface`) directly when you want to publish a *custom* event (e.g. progress on a long-running command).
```php
namespace PhpQml\Bridge;
final class ModelPublisher implements ModelPublisherInterface
{
public function publishEntityChange(object $entity, BridgeOp $op): void;
}
```
> **API break in v0.2.0:** the second arg used to be `string $op`. It is now the typed `BridgeOp` enum — typo'd ops are caught at compile time instead of silently producing envelopes clients ignore. Migration: replace raw `'upsert'` / `'delete'` strings with `BridgeOp::Upsert` / `BridgeOp::Delete`.
`$op` is one of the `BridgeOp` cases. The published JSON payload:
```json
{
@@ -107,14 +178,14 @@ final class MarkAllDoneController
{
public function __construct(
private EntityManagerInterface $em,
private ModelPublisher $publisher,
private ModelPublisherInterface $publisher,
) {}
public function __invoke(): JsonResponse
{
foreach ($this->em->getRepository(Todo::class)->findAll() as $todo) {
$todo->setDone(true);
$this->publisher->publishEntityChange($todo, 'upsert');
$this->publisher->publishEntityChange($todo, BridgeOp::Upsert);
}
$this->em->flush();
return new JsonResponse(['ok' => true]);
@@ -128,25 +199,14 @@ In practice you usually don't need to call `publishEntityChange` manually — `f
## `CorrelationContext`
Request-scoped service holding the current `Idempotency-Key`. The bundle's `CorrelationKeyListener` reads the request header into it; `ModelPublisher` reads it back when emitting events.
```php
namespace PhpQml\Bridge;
final class CorrelationContext
{
public function set(?string $key): void;
public function get(): ?string;
public function clear(): void;
}
```
Request-scoped service holding the current `Idempotency-Key`. The bundle's `CorrelationKeyListener` reads the request header into it; `ModelPublisher` reads it back when emitting events. Implements [`CorrelationContextInterface`](#correlationcontextinterface).
You rarely need to touch this — it auto-plumbs the `correlationKey` field on every `ModelPublisher` event. Inject it if you're writing a custom controller that publishes through some other mechanism and wants to thread the same key through.
```php
final class CustomController
{
public function __invoke(CorrelationContext $ctx, /* … */): JsonResponse
public function __invoke(CorrelationContextInterface $ctx, /* … */): JsonResponse
{
$key = $ctx->get(); // → "01HX…" (uuid set by the QML client)
// …
@@ -156,6 +216,34 @@ final class CustomController
---
## `BridgeBundleInfo`
Value object carrying the bundle's name + class FQCN. Used by `HealthController` as the deep-load canary on `/healthz` — if the container can construct `BridgeBundleInfo`, the bundle is wired up correctly.
```php
namespace PhpQml\Bridge;
final readonly class BridgeBundleInfo
{
public function __construct(
public string $name, // 'php-qml/bridge'
public string $class, // PhpQml\Bridge\BridgeBundle::class
) {}
}
```
App code rarely injects this directly — but if you're rolling a custom `/healthz` endpoint and want the same canary semantic without coupling to `Publisher` (which `/healthz` used to do pre-v0.2.0), this is the shape to typehint.
`/healthz` response shape (changed in v0.2.0):
```json
{ "status": "ok", "name": "php-qml/bridge", "bundle": "PhpQml\\Bridge\\BridgeBundle" }
```
Pre-v0.2.0 the `bundle` field was `PhpQml\Bridge\Publisher`. Consumers asserting that exact value need to migrate; consumers reading any-truthy / unknown-keys-ok are unaffected.
---
## `bridge:doctor`
Console command. Verifies a dev environment is set up correctly.
@@ -184,6 +272,26 @@ Exit code `0` if everything passes, non-zero otherwise. CI runs this as part of
---
## `bridge:export`
Console command. Copies the active SQLite database to a user-chosen path.
```bash
bin/console bridge:export /home/me/backup-2026-05-03.sqlite
# → wrote 1245184 bytes to /home/me/backup-2026-05-03.sqlite
```
Behaviour:
- Reads the source path from `DATABASE_URL`. Works in dev and bundled mode without configuration.
- Overwrites the destination if it exists.
- Errors with exit code `1` if `DATABASE_URL` doesn't point at a SQLite file (`sqlite:///…`), or the source file doesn't exist.
- Mirrored on the QML side as [`BackendConnection.exportDatabase(path)`](qml-api.md#exportdatabase) — apps typically pair the QML hook with `Qt.labs.platform.FileDialog` so the user picks a destination natively (see [Native dialogs §file pickers](native-dialogs.md#file-pickers)).
This is the export half of a "backup my data" UX. The restore half is just `cp <backup> <data-dir>/data.sqlite` while the app is closed; bundled mode also keeps automatic [pre-migration backups](bundled-mode.md#pre-migration-auto-backup) for the migration-corruption case.
---
## Event subscribers
These run automatically; documented for awareness.
@@ -215,7 +323,12 @@ If you want to layer real user authentication on top (e.g. an app that has multi
```
framework/php/
├── src/
│ ├── BridgeBundle.php bundle registration
│ ├── BridgeBundle.php bundle registration + DI extension
│ ├── BridgeBundleInfo.php deep-load canary value object
│ ├── BridgeOp.php wire-format enum
│ ├── PublisherInterface.php ─┐
│ ├── ModelPublisherInterface.php │ public service interfaces
│ ├── CorrelationContextInterface.php ─┘
│ ├── Attribute/BridgeResource.php
│ ├── ModelPublisher.php dual-publish + version increment
│ ├── Publisher.php thin Mercure facade
@@ -225,9 +338,12 @@ framework/php/
│ │ └── CorrelationKeyListener.php request → context
│ ├── EventListener/ Doctrine + Symfony listeners
│ ├── Command/
│ │ ── BridgeDoctorCommand.php bridge:doctor
│ │ ── BridgeDoctorCommand.php bridge:doctor
│ │ └── BridgeExportCommand.php bridge:export
│ ├── Controller/ (skeleton route resource lives here)
── Maker/ symfony/maker-bundle integrations
── Maker/ symfony/maker-bundle integrations
│ │ └── Support/ shared helpers (NameInput, Naming)
│ └── ReadModel/ (apps' read-model query services land here)
├── config/services.yaml service wiring
└── tests/ unit + integration + maker snapshot
```

View File

@@ -38,8 +38,9 @@ QML singleton. Lifecycle owner: detects dev vs bundled mode at construction, sup
| Method | Description |
| --- | --- |
| `restart()` | Bundled mode: tear down + respawn FrankenPHP. Dev mode: re-probe. |
| `checkForUpdates()` | Bundled mode: invoke AppImageUpdate sidecar `--check-for-update`. |
| `applyUpdate()` | Bundled mode: invoke AppImageUpdate sidecar `--remove-old`. |
| `checkForUpdates()` | Bundled mode: invoke AppImageUpdate sidecar `--check-for-update`. The supervisor also calls this automatically on launch (10 s after `Online`) and every 6 h thereafter — see [Bundled mode §periodic check](bundled-mode.md#periodic-check). |
| `applyUpdate()` | Bundled mode: invoke AppImageUpdate sidecar `--remove-old`. Never auto-restarts the app. |
| `exportDatabase(path)` | `Q_INVOKABLE bool`. Copies the active SQLite database to `path`; returns success synchronously and emits `databaseExported(path)` / `databaseExportFailed(reason)` for async UX. Mirrors the `bridge:export` console command. See below. |
| `childLogTail()` | Bundled mode: returns `QStringList` of last ≤500 child output lines. |
### Signals
@@ -53,6 +54,8 @@ QML singleton. Lifecycle owner: detects dev vs bundled mode at construction, sup
| `updateCheckFailed(QString reason)` | Sidecar errored, env unset, or dev mode. |
| `updateApplied()` | Update was downloaded and applied; user should restart. |
| `updateApplyFailed(QString reason)` | Apply errored. |
| `databaseExported(QString path)` | `exportDatabase()` succeeded. |
| `databaseExportFailed(QString reason)` | `exportDatabase()` errored (non-SQLite `DATABASE_URL`, missing source, write failed). |
| `childLogLine(QString line)` | Emitted per line read from the bundled child's merged stdout+stderr. |
### Example
@@ -72,6 +75,32 @@ Item {
}
```
### `exportDatabase`
Pair with `Qt.labs.platform.FileDialog` so the user picks a destination natively:
```qml
import Qt.labs.platform as Platform
Platform.FileDialog {
id: saveDlg
title: "Export database"
fileMode: Platform.FileDialog.SaveFile
nameFilters: ["SQLite (*.sqlite)"]
onAccepted: BackendConnection.exportDatabase(Qt.url.toLocalFile(currentFile))
}
Connections {
target: BackendConnection
function onDatabaseExported(path) { tray.showMessage("Saved", path) }
function onDatabaseExportFailed(reason) { error.text = reason }
}
Button { text: "Export…"; onClicked: saveDlg.open() }
```
`exportDatabase()` returns synchronously (`true` on success, `false` on failure) — the signals exist for cases where the caller is decoupled from the click handler. See [PHP API §bridge:export](php-api.md#bridgeexport) for the equivalent CLI command.
---
## `RestClient`

View File

@@ -28,13 +28,21 @@ ReactiveListModel::~ReactiveListModel()
qDeleteAll(m_echoTimers);
}
void ReactiveListModel::componentComplete()
{
m_complete = true;
if (!m_baseUrl.isEmpty() && !m_source.isEmpty()) {
refresh();
}
}
void ReactiveListModel::setBaseUrl(const QString& v)
{
if (m_baseUrl == v) return;
m_baseUrl = v;
rewireMercure();
emit baseUrlChanged();
if (!m_source.isEmpty()) refresh();
if (m_complete && !m_source.isEmpty()) refresh();
}
void ReactiveListModel::setToken(const QString& v)
@@ -50,7 +58,7 @@ void ReactiveListModel::setSource(const QString& v)
if (m_source == v) return;
m_source = v;
emit sourceChanged();
if (!m_baseUrl.isEmpty()) refresh();
if (m_complete && !m_baseUrl.isEmpty()) refresh();
}
void ReactiveListModel::setTopic(const QString& v)

View File

@@ -4,6 +4,7 @@
#include <QHash>
#include <QJsonArray>
#include <QJsonObject>
#include <QQmlParserStatus>
#include <QString>
#include <QVector>
#include <QtQmlIntegration>
@@ -27,9 +28,10 @@ class MercureClient;
/// version-gap detection. Cursor pagination is wired but the default
/// "fetch everything" behaviour is fine for small collections; bigger
/// resources should set `pageSize` and call `fetchMore()` from the view.
class ReactiveListModel : public QAbstractListModel
class ReactiveListModel : public QAbstractListModel, public QQmlParserStatus
{
Q_OBJECT
Q_INTERFACES(QQmlParserStatus)
QML_ELEMENT
Q_PROPERTY(QString baseUrl READ baseUrl WRITE setBaseUrl NOTIFY baseUrlChanged)
@@ -43,6 +45,16 @@ public:
explicit ReactiveListModel(QObject* parent = nullptr);
~ReactiveListModel() override;
// QQmlParserStatus — lets us defer the initial fetch until ALL
// bindings have landed. Without this, a setter that sees enough
// state to fetch (baseUrl + source) can fire `refresh()` before
// the binding for `token` has run, sending an unauthenticated GET
// and parking an empty model. componentComplete() is the single
// safe trigger for the first fetch; later setter changes still
// fire refresh() inline as before.
void classBegin() override {}
void componentComplete() override;
// QAbstractListModel
int rowCount(const QModelIndex& parent = QModelIndex()) const override;
QVariant data(const QModelIndex& index, int role) const override;
@@ -121,6 +133,7 @@ private:
QString m_source;
QString m_topic;
bool m_ready = false;
bool m_complete = false; // QQmlParserStatus marker
QString m_error;
QNetworkAccessManager* m_nam = nullptr;

View File

@@ -29,13 +29,21 @@ ReactiveObject::~ReactiveObject()
qDeleteAll(m_echoTimers);
}
void ReactiveObject::componentComplete()
{
m_complete = true;
if (!m_baseUrl.isEmpty() && !m_source.isEmpty()) {
refresh();
}
}
void ReactiveObject::setBaseUrl(const QString& v)
{
if (m_baseUrl == v) return;
m_baseUrl = v;
rewireMercure();
emit baseUrlChanged();
if (!m_source.isEmpty()) refresh();
if (m_complete && !m_source.isEmpty()) refresh();
}
void ReactiveObject::setToken(const QString& v)
@@ -50,7 +58,7 @@ void ReactiveObject::setSource(const QString& v)
if (m_source == v) return;
m_source = v;
emit sourceChanged();
if (!m_baseUrl.isEmpty()) refresh();
if (m_complete && !m_baseUrl.isEmpty()) refresh();
}
void ReactiveObject::setTopic(const QString& v)

View File

@@ -3,6 +3,7 @@
#include <QHash>
#include <QJsonObject>
#include <QObject>
#include <QQmlParserStatus>
#include <QQmlPropertyMap>
#include <QString>
#include <QtQmlIntegration>
@@ -26,9 +27,10 @@ class MercureClient;
/// ReactiveListModel.invoke(): apply locally + Idempotency-Key + roll
/// back on `4xx`/`5xx`/timeout, clear `pending` on the matching
/// Mercure echo (PLAN.md §5).
class ReactiveObject : public QObject
class ReactiveObject : public QObject, public QQmlParserStatus
{
Q_OBJECT
Q_INTERFACES(QQmlParserStatus)
QML_ELEMENT
Q_PROPERTY(QString baseUrl READ baseUrl WRITE setBaseUrl NOTIFY baseUrlChanged)
@@ -45,6 +47,13 @@ public:
explicit ReactiveObject(QObject* parent = nullptr);
~ReactiveObject() override;
// QQmlParserStatus — defer the initial fetch to componentComplete()
// so the GET goes out with token + baseUrl + source all populated,
// regardless of which order QML evaluated the bindings. See the
// matching note on ReactiveListModel.
void classBegin() override {}
void componentComplete() override;
QString baseUrl() const { return m_baseUrl; }
void setBaseUrl(const QString& v);
@@ -113,6 +122,7 @@ private:
bool m_ready = false;
bool m_pending = false;
bool m_exists = false;
bool m_complete = false; // QQmlParserStatus marker
QString m_error;
QQmlPropertyMap* m_data = nullptr;

View File

@@ -8,13 +8,36 @@
#
# Or from the skeleton / example Makefiles via `make qmltest`.
find_package(Qt6 6.5 REQUIRED COMPONENTS QuickTest)
find_package(Qt6 6.5 REQUIRED COMPONENTS QuickTest Network)
# A tiny PhpQml.Bridge.Tests QML module that exposes the in-process
# stub HTTP server used by tst_reactive_list_model.qml. Static so it
# links into the test exe alongside the production bridge module.
qt_add_qml_module(php_qml_bridge_tests
URI PhpQml.Bridge.Tests
VERSION 1.0
STATIC
OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/PhpQml/Bridge/Tests
SOURCES
TestHttpServer.h
TestHttpServer.cpp
)
target_link_libraries(php_qml_bridge_tests PUBLIC
Qt6::Core
Qt6::Network
Qt6::Qml
)
qt_add_executable(qml_unit_tests main.cpp)
target_link_libraries(qml_unit_tests PRIVATE
Qt6::QuickTest
Qt6::Qml
Qt6::Quick
php_qml_bridge # production module — type implementations
php_qml_bridgeplugin # …and its auto-generated QQmlEngineExtensionPlugin
php_qml_bridge_tests # in-process HTTP stub
php_qml_bridge_testsplugin # …and its plugin
)
# QUICK_TEST_MAIN reads QUICK_TEST_SOURCE_DIR from the macro definition

View File

@@ -0,0 +1,115 @@
#include "TestHttpServer.h"
#include <QHostAddress>
#include <QTcpSocket>
namespace PhpQml::Bridge::Tests {
TestHttpServer::TestHttpServer(QObject* parent)
: QObject(parent)
{
m_server.listen(QHostAddress::LocalHost, 0);
connect(&m_server, &QTcpServer::newConnection,
this, &TestHttpServer::onNewConnection);
}
QString TestHttpServer::url() const
{
return QStringLiteral("http://127.0.0.1:%1").arg(m_server.serverPort());
}
void TestHttpServer::setResponseBody(const QString& v)
{
if (m_responseBody == v) return;
m_responseBody = v;
emit responseBodyChanged();
}
void TestHttpServer::setResponseStatus(int v)
{
if (m_responseStatus == v) return;
m_responseStatus = v;
emit responseStatusChanged();
}
void TestHttpServer::onNewConnection()
{
while (auto* sock = m_server.nextPendingConnection()) {
// One buffer per socket, owned by the socket so it dies with it.
// (The original thread_local trick leaked between connections.)
auto* buffer = new QByteArray;
connect(sock, &QObject::destroyed, [buffer]() { delete buffer; });
connect(sock, &QTcpSocket::readyRead, this, [this, sock, buffer]() {
buffer->append(sock->readAll());
const int headerEnd = buffer->indexOf("\r\n\r\n");
if (headerEnd < 0) return;
const QByteArray headerBlock = buffer->left(headerEnd);
buffer->clear();
const QList<QByteArray> lines = headerBlock.split('\n');
QString requestLine;
QString authHeader;
for (int i = 0; i < lines.size(); ++i) {
QByteArray line = lines[i];
if (line.endsWith('\r')) line.chop(1);
if (i == 0) {
requestLine = QString::fromUtf8(line);
continue;
}
const int colon = line.indexOf(':');
if (colon < 0) continue;
const QByteArray name = line.left(colon).trimmed();
const QByteArray value = line.mid(colon + 1).trimmed();
if (name.compare("Authorization", Qt::CaseInsensitive) == 0) {
authHeader = QString::fromUtf8(value);
}
}
// Only count + capture metrics for /api/… GETs. SSE reconnect
// attempts from MercureClient hit /.well-known/mercure on the
// same port and would otherwise inflate the request count and
// overwrite the captured headers we want to assert against.
const bool isApiGet = requestLine.startsWith(QStringLiteral("GET /api/"));
if (isApiGet) {
if (m_lastRequestLine != requestLine) {
m_lastRequestLine = requestLine;
emit lastRequestLineChanged();
}
if (m_lastAuthHeader != authHeader) {
m_lastAuthHeader = authHeader;
emit lastAuthHeaderChanged();
}
++m_apiGetCount;
emit apiGetCountChanged();
}
// For /api/ routes, mimic SessionAuthenticator and reject
// requests without an Authorization header. This is what
// exposes the property-order race in the regression test:
// pre-fix, the GET went out unauthenticated, this server
// returned 401, and the model parked with `ready === false`.
const bool needAuth = isApiGet;
const bool isAuthed = !authHeader.isEmpty();
const bool reject = needAuth && !isAuthed;
const int status = reject ? 401 : m_responseStatus;
const QByteArray body = reject
? QByteArrayLiteral(R"({"type":"about:blank","title":"Unauthorized","status":401})")
: m_responseBody.toUtf8();
QByteArray resp;
resp.append("HTTP/1.1 ").append(QByteArray::number(status))
.append(' ').append(status == 200 ? "OK" : "STATUS").append("\r\n");
resp.append("Content-Type: application/json\r\n");
resp.append("Content-Length: ").append(QByteArray::number(body.size())).append("\r\n");
resp.append("Connection: close\r\n\r\n");
resp.append(body);
sock->write(resp);
sock->disconnectFromHost();
});
connect(sock, &QTcpSocket::disconnected, sock, &QObject::deleteLater);
}
}
} // namespace PhpQml::Bridge::Tests

View File

@@ -0,0 +1,76 @@
// Tiny localhost HTTP server for qmltest fixtures. Listens on a free
// ephemeral port; for any incoming request, captures the request line +
// headers and replies with a fixed JSON body. Exposed to QML as the
// `TestHttpServer` element so tests can instantiate one inline:
//
// TestHttpServer {
// id: srv
// responseBody: '[{"id":"1","title":"a","done":false}]'
// }
// ReactiveListModel { baseUrl: srv.url; ... }
// compare(srv.lastAuthHeader, "Bearer testtoken")
//
// Just enough HTTP to serve a single line-of-sight request — no
// chunked encoding, no keepalive, no Content-Length parsing on the
// way in. The framework's network paths only ever issue GET /…
// against this stub during the test, so that's all we need.
//
// `apiGetCount` counts only requests under `/api/…` so tests can
// distinguish the model's HTTP fetches from Mercure's SSE reconnect
// attempts (which hit `/.well-known/mercure`).
#pragma once
#include <QObject>
#include <QTcpServer>
#include <QtQmlIntegration>
namespace PhpQml::Bridge::Tests {
class TestHttpServer : public QObject
{
Q_OBJECT
QML_ELEMENT
Q_PROPERTY(int port READ port CONSTANT)
Q_PROPERTY(QString url READ url CONSTANT)
Q_PROPERTY(QString responseBody READ responseBody WRITE setResponseBody NOTIFY responseBodyChanged)
Q_PROPERTY(int responseStatus READ responseStatus WRITE setResponseStatus NOTIFY responseStatusChanged)
Q_PROPERTY(int apiGetCount READ apiGetCount NOTIFY apiGetCountChanged)
Q_PROPERTY(QString lastAuthHeader READ lastAuthHeader NOTIFY lastAuthHeaderChanged)
Q_PROPERTY(QString lastRequestLine READ lastRequestLine NOTIFY lastRequestLineChanged)
public:
explicit TestHttpServer(QObject* parent = nullptr);
int port() const { return m_server.serverPort(); }
QString url() const;
QString responseBody() const { return m_responseBody; }
int responseStatus() const { return m_responseStatus; }
int apiGetCount() const { return m_apiGetCount; }
QString lastAuthHeader() const { return m_lastAuthHeader; }
QString lastRequestLine() const { return m_lastRequestLine; }
void setResponseBody(const QString& v);
void setResponseStatus(int v);
signals:
void responseBodyChanged();
void responseStatusChanged();
void apiGetCountChanged();
void lastAuthHeaderChanged();
void lastRequestLineChanged();
private slots:
void onNewConnection();
private:
QTcpServer m_server;
QString m_responseBody = QStringLiteral("[]");
int m_responseStatus = 200;
int m_apiGetCount = 0;
QString m_lastAuthHeader;
QString m_lastRequestLine;
};
} // namespace PhpQml::Bridge::Tests

View File

@@ -4,6 +4,18 @@
//
// PLAN.md §13 v0.2.0 testing-strategy row.
#include <QtPlugin>
#include <QtQuickTest/quicktest.h>
// Static QML modules need their auto-generated plugin classes pulled
// in explicitly — the linker would otherwise strip the registration
// init code because nothing in main() references it. Without these
// imports the QmlEngine that QUICK_TEST_MAIN spins up can't resolve
// `import PhpQml.Bridge` / `import PhpQml.Bridge.Tests`.
//
// Plugin class names are auto-generated by qt_add_qml_module(STATIC)
// from the URI: dots become underscores, suffixed with "Plugin".
Q_IMPORT_PLUGIN(PhpQml_BridgePlugin)
Q_IMPORT_PLUGIN(PhpQml_Bridge_TestsPlugin)
QUICK_TEST_MAIN(qml_unit_tests)

View File

@@ -0,0 +1,118 @@
// Regression test for the property-assignment-order bug that left a
// second window's ReactiveListModel empty on first open. Both
// reproductions cover the same root cause: setBaseUrl() / setSource()
// used to fire `refresh()` inline, which meant whichever setter
// happened to land *last* triggered the GET — and that GET captured
// whatever m_token was at that exact instant. setToken() never fires
// refresh() itself, so if QML evaluated `token` after `baseUrl` /
// `source`, the first GET went out unauthenticated and the model
// parked an empty list.
//
// The fix defers the initial fetch to QQmlParserStatus::componentComplete().
// By that point every binding (literal *and* singleton-derived) has
// landed, so refresh() picks up the bearer.
import QtQuick
import QtTest
import PhpQml.Bridge
import PhpQml.Bridge.Tests
TestCase {
name: "ReactiveListModel"
when: windowShown
// ── Stub backend ────────────────────────────────────────────────
TestHttpServer {
id: srv
responseBody: '[{"id":"1","title":"hello","done":false}]'
}
// Stand-in for the BackendConnection singleton — exposes the same
// shape (`url`, `token` properties) so the model's bindings depend
// on a third object the same way the production code does. This is
// what reproduces the property-evaluation-order race: when both
// `baseUrl` and `token` are bindings (rather than literals), QML
// evaluates them together in the binding-evaluation phase, *after*
// the literal `source` has been assigned. Pre-fix, the binding
// for `baseUrl` fires `refresh()` inline and the request goes out
// before the binding for `token` has run.
QtObject {
id: backend
property string url: srv.url
property string token: "testtoken"
}
// ── Reproduction A: declarative model with bindings up-front ────
// This is the exact shape examples/todo/qml/TodoWindow.qml uses
// for the second window. Without the fix, the setter that lands
// *second* of {baseUrl, source} fires `refresh()` inline — and
// because QML evaluates literal values before bindings to other
// objects' properties, that setter typically lands before `token`.
// The GET goes out unauthenticated, the test server returns 401,
// and the model parks with `ready === false`. The fix defers the
// initial fetch to componentComplete() so the bearer is always in
// place by the time the request fires.
Component {
id: declarativeModel
ReactiveListModel {
// Same shape as examples/todo/qml/TodoWindow.qml — literals
// for source/topic, bindings to a stand-in BackendConnection
// for baseUrl/token. Without the fix the GET fires before
// `token` lands and the test server's auth check rejects
// it; the model parks at ready === false.
source: "/api/todos"
topic: "app://model/todo"
baseUrl: backend.url
token: backend.token
}
}
function test_declarative_creation_sends_token_on_first_get() {
const baseline = srv.apiGetCount
const m = declarativeModel.createObject(null)
verify(m, "model instance was created")
// Wait for the GET to land. With the fix, the request fires
// exactly once after componentComplete with the bearer set.
tryCompare(srv, "apiGetCount", baseline + 1, 2000,
"ReactiveListModel issued exactly one /api/ GET")
compare(srv.lastAuthHeader, "Bearer testtoken",
"first GET carries the Authorization header — without the fix this is empty")
compare(srv.lastRequestLine, "GET /api/todos HTTP/1.1",
"request line addresses the configured source path")
tryCompare(m, "ready", true, 2000)
m.destroy()
}
// ── Reproduction B: post-componentComplete imperative changes ───
// Once the component is complete, individual setter changes still
// need to trigger refresh inline. This case verifies the fix
// doesn't accidentally suppress refresh forever — only during the
// initial property-assignment pass.
Component {
id: bareModel
ReactiveListModel {}
}
function test_imperative_property_set_after_completion() {
// Each test reuses the same TestHttpServer instance; check the
// delta from a snapshot taken now.
const baseline = srv.apiGetCount
const m = bareModel.createObject(null)
verify(m)
m.token = "imperativeToken"
m.topic = "app://model/todo"
m.source = "/api/todos"
m.baseUrl = srv.url // last of the {baseUrl, source} pair → triggers fetch
tryCompare(srv, "apiGetCount", baseline + 1, 2000)
compare(srv.lastAuthHeader, "Bearer imperativeToken",
"imperative setBaseUrl after token is set fetches with token")
m.destroy()
}
}