CHANGELOG.md: new [0.1.1] section with Fixed (HealthController
deep-load, Caddyfile fmt) + Added (bundled-mode supervisor test,
skeleton AppImage parity) + a Notes line acknowledging the
port-collision bug deferred to v0.2.0. Date stays TBD until tag push.
Compare/tag link refs updated.
PLAN.md: v0.1.1 section flipped from "open follow-ups" to "ready to
tag" with each item describing what shipped (handy for the release
notes pass). v0.2.0 section gains an explicit "Bundled-mode port
negotiation" entry under Operations — the port-collision bug
surfaced during v0.1.1 prep, but the fix touches every consumer that
hardcodes 8765 (perfsmoke, the new bundled-supervisor test) so it's
wider than v0.1.x scope. Status line at the head of the file bumped
to "v0.1.1 ready to tag".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The v0.1.0 shakedown fixes for AppImage assembly (path-repo
symlink:false sed, writable-cache redirect) all landed in
examples/todo. The skeleton — which is what bin/php-qml-init copies
when scaffolding a new app — had no `appimage` target at all, so every
scaffolded app would have to either copy the example's Makefile by
hand or re-discover the same shakedown bugs.
Brings parity:
- framework/skeleton/Makefile gains `staging-symfony` and `appimage`
targets, mirroring the example's. Two new variables (BUNDLE_SRC,
PACKAGING) parameterise the framework-tree paths so bin/php-qml-init
can rewrite them at scaffold time without sed-touching the recipe.
- framework/skeleton/packaging/skeleton.{desktop,png} added — minimum
surface for the AppImage assembly to succeed without the user
needing to author them.
- framework/skeleton/Makefile's staging-symfony recipe handles both
relative (framework default `../../php`) and absolute (post-scaffold)
BUNDLE_SRC values via a case statement.
- bin/php-qml-init renames packaging/skeleton.* → packaging/$NAME.*,
rewrites the .desktop file's Name/Exec/Icon, and updates the
Makefile's --app-name / --output / --desktop / --icon flags +
BUNDLE_SRC + PACKAGING variables. For --vendor mode, framework's
packaging/linux/ is also vendored to .bridge-packaging/ alongside
the existing .bridge/ + .bridge-qml/.
Verified by scaffolding both modes:
- non-vendored: BUNDLE_SRC + PACKAGING absolute paths
- --vendor: BUNDLE_SRC=../.bridge, PACKAGING=.bridge-packaging,
.bridge-packaging/ contains build-appimage.sh
Skeleton's `make quality` still green; staging-symfony works locally
(vendor/php-qml/bridge resolves to a real directory, not a symlink).
Closes the v0.1.1 follow-up "bin/php-qml-init parity" tracked in
PLAN.md §13.
Bundled drive-by: docs/makers.md picked up two markdownlint auto-fixes
(blank lines around lists) when the IDE saved.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Stages a fake AppImage layout in /tmp without a real .AppImage build:
$ROOT/usr/bin/<app> — copy of the host binary
$ROOT/usr/bin/frankenphp — symlink to system frankenphp
$ROOT/usr/share/<app>/symfony — staged --no-dev composer copy
$ROOT/usr/share/<app>/Caddyfile
The staged Symfony tree is `chmod -R a-w` to actually exercise the
read-only-mount cache/log redirect (Kernel::getCacheDir +
APP_CACHE_DIR override) — without the override, Symfony would fail
to mkdir var/cache/prod and migrations would error out.
Then runs the host with BRIDGE_URL unset (forces bundled mode), polls
/healthz, and asserts:
- status=ok + bundle="PhpQml\Bridge\Publisher" — proves the
HealthController deep-load (predecessor commit) actually
autowired Publisher, i.e. BridgeBundle is reachable.
- User data dir's var/cache exists — APP_CACHE_DIR override fired.
- Staged tree's var/cache/prod is empty — Symfony didn't write into
the read-only mount.
Together this catches every v0.1.0 shakedown bug in CI:
- doubled bin/frankenphp path (resolveFrankenphpBin)
- composer path-repo symlink dangling (staging-symfony's symlink:false sed)
- read-only mount cache failure (Kernel + supervisor env-vars)
- bundle autoload broken (HealthController canary)
Implementation gotcha (caught during dev): the host binary must be
COPIED into the staged layout, not symlinked. Qt's
applicationDirPath() reads /proc/self/exe which dereferences
symlinks, so a symlinked host would resolve to the original build/
dir and the supervisor would hunt for frankenphp + symfony there
instead of the staged tree. Real AppImages copy the binary, mimicking
that here.
Wiring:
- examples/todo/Makefile: extracted the staging-symfony logic out
of the appimage target into its own staging-symfony target. New
integration-bundled target depends on `build` + `staging-symfony`
and runs tests/bundled-supervisor.sh. quality target now invokes
integration-bundled after the existing dev-mode integration test.
- .gitea/workflows/ci.yml: new "Bundled-mode supervisor integration
test" step right after the dev-mode integration step.
Closes the v0.1.1 follow-up "Bundled-mode integration test" tracked
in PLAN.md §13.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v0.1.0 shipped two bugs that left /healthz returning 200 against a
half-loaded bundle: the path-repo symlink dangling at runtime in the
AppImage (vendor/php-qml/bridge → nonexistent), and the writable
cache-dir bug (Symfony couldn't create var/cache/prod). HealthController
returned a static {status:"ok"} without ever touching any BridgeBundle
service, so perfsmoke + the connection-state probe both passed even
when the bundle's autoload was broken — first sign of trouble was a
500 from /api/todos under real load.
Inject Publisher (the bundle's Mercure-publish wrapper) via constructor
and reference its FQN in the response body. Two effects:
- Symfony's container resolves Publisher when the controller is
instantiated; if the bundle's autoload is broken, the controller
can't even construct, /healthz returns 500.
- The response now includes `bundle: "PhpQml\Bridge\Publisher"` —
proves to perfsmoke + dev console that the canary is live, not a
cached static response.
Connection-state probe semantics unchanged: still 200 = Online,
non-200 = Reconnecting/Offline. Probe interval is 5s — Publisher's
construction is constant-time, no perf concern.
No new public API: /healthz response gained a `bundle` field
(additive, JSON parsers ignore unknown keys); 200 vs 500 boundary is
preserved. No existing consumer broken.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
FrankenPHP logs a warning on every boot:
Caddyfile input is not formatted; run 'caddy fmt --overwrite' to
fix inconsistencies
Cosmetic but clutters the dev console (and the bundled-mode logs).
The actual diff is one blank line in each file: caddy fmt rejects an
empty line between a leading comment and the `{` global-options
block. tests/var/Caddyfile was already clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Same logic as the macOS/Windows + telemetry moves: alternate
distribution channels are operational work (Flatpak manifest +
Flathub PR review; snapcraft.yaml + Snap Store listing) that fits
the cross-platform packaging milestone, not the v0.3.0 grab-bag.
Tightened the v0.9.0 framing to make this explicit: AppImage is the
only packaged target through v0.2.0, v0.3.0, and the v1.0.0 prep —
all packaging churn concentrated into v0.9.0.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Telemetry was split awkwardly: v0.3.0 had "Sentry + opt-in telemetry"
(build the pipeline) and v1.0.0 had "Telemetry / crash reporting
opt-in plumbing" (settle the API). The cross-platform crash-dump side
is per-OS work — Apple Crash Reporter, Windows WER, Linux core dumps
all differ — so it naturally rides with the v0.9.0 cross-platform
packaging push rather than landing twice.
Single v0.9.0 entry now covers both: PHP-side Sentry + per-platform
crash-dump pipeline, opt-in only, plumbing settled before v1.0.0 even
if no default endpoint ships.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Each cross-platform target carries operational prerequisites that
v0.2.0 isn't ready to absorb (Apple Developer cert + notarisation
pipeline + macOS runner; Authenticode cert + SmartScreen reputation
warm-up + Windows runner). Folding both into the next minor would
either delay v0.2.0 indefinitely or ship a half-done port.
Better: keep Linux AppImage as the only packaged target until the
framework's public API surface settles, then concentrate the
cross-platform push into a single v0.9.0 release-candidate milestone
right before v1.0.0. The §11 *Distribution UX* foot-guns (Gatekeeper,
SmartScreen, AV pre-submissions, file-association docs) ride along
with that milestone.
v0.2.0 stays focused on the smaller deferred items (deferred makers,
ReactiveObject pagination, qmltestrunner, end-to-end UI test,
auto-backup, bridge:export, periodic auto-update, build-time cache
warmup, native-dialog boundary doc) — all things a Linux-only
contributor can deliver without operational blockers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`github.server_url` on Gitea Actions resolves to the runner's
internal Gitea endpoint (e.g. http://gitea:3000) — fine for API
calls the runner makes itself, broken for URLs end-user machines
have to resolve. Both v0.1.0 user-facing places used it:
- latest.json's `url` and `zsync` fields (read by AppImageUpdate
on user machines).
- The AppImage's embedded `--update-info` ELF section (also read
by AppImageUpdate to find the appcast).
Result: v0.1.0's latest.json shipped pointing at gitea:3000, which
no end-user machine can reach.
Fix: add a job-level `PUBLIC_REPO_URL` env var (single source of
truth, easy to change if Gitea ever moves) and use it for both
artefact-URL composition sites. The release-create + asset-upload
API calls keep using `github.server_url`/`api/v1` — those are
runner→Gitea internal traffic where the internal URL is correct.
Note: v0.1.0's already-uploaded latest.json still has the broken
URLs. Either leave it (no auto-update consumers yet) or PATCH the
asset out of band; future tags will be correct once this lands.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pre-1.0 releases get the prerelease flag in Gitea — pre-1.0 means
public API may break between minors (SemVer permits this), so these
shouldn't display as stable releases.
Computed from $TAG via `case` so the flag auto-flips to false when
v1.0.0 lands; no further workflow change needed at that point.
case "$TAG" in
v0.*) prerelease=true ;;
*) prerelease=false ;;
esac
Passed to jq via --argjson (not --arg) so it stays a JSON boolean
rather than the string "true" / "false".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
§13 was 332 lines of phase-by-phase implementation history (Phase 0
spike → Phase 5 closure). All of that is now redundant with
CHANGELOG.md (per-version summary) and `git log` (per-commit detail);
keeping it in PLAN.md was duplication that would only rot.
Replaced with a Versions section organised around SemVer:
- v0.1.0 — shipped 2026-05-03 (links to CHANGELOG + release page).
- v0.1.1 — bugfix follow-ups from v0.1.0 shakedown (perfsmoke gap,
bin/php-qml-init parity with the AppImage fixes that landed in
examples/todo, bundled-mode integration test, Caddyfile fmt).
- v0.2.0 — minor features: macOS/Windows packaging (was Phase 4b/4c),
deferred makers (event/read-model), ReactiveObject pagination,
qmltestrunner + end-to-end UI test, pre-migration auto-backup,
bridge:export, periodic auto-update check, build-time cache warmup,
native-dialogs boundary doc.
- v0.3.0 — bigger pieces: i18n bridge, persistent log files +
rotation, multi-arch builds, Sentry + opt-in telemetry, Flathub /
Snap, Composer create-project package.
- v1.0.0 — when public API stabilises: auth model, Mercure storage,
AppImage relinkability, telemetry plumbing, security audit,
FrankenPHP-as-library evaluation.
Every deferred item from the original §11/§12/Phase 3-5 deferral
lists got a version target — no orphans.
Top-of-file status line and "Where else to look" pointer added so
readers know docs/ is for how-to-use, CHANGELOG for what-shipped, and
PLAN.md keeps why + what's next.
Net: 836 → 561 lines (33% smaller). §1-12 (architectural rationale)
unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 12:48:19 +02:00
14 changed files with 358 additions and 321 deletions
@@ -10,6 +10,24 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
- (none yet — next changes land here)
## [0.1.1] — 2026-05-03
Bugfix release closing the four follow-ups identified during the v0.1.0 shakedown. No new public API surface; `/healthz` response gains an additive `bundle` field (existing JSON consumers ignore unknown keys).
### Fixed
- **`HealthController` deep-loads the bundle.** Constructor-injects `Publisher` so `/healthz` returns 200 only when `BridgeBundle` is fully container-resolvable. v0.1.0's `/healthz` returned 200 against half-loaded bundles — both the path-repo symlink dangling at runtime and the read-only-cache failure shipped green through perfsmoke as a result. Response body now includes `bundle: "PhpQml\\Bridge\\Publisher"` as the canary value.
- **Caddyfile formatting.** `framework/skeleton/Caddyfile` and `examples/todo/Caddyfile` reformatted with `caddy fmt`. The "Caddyfile input is not formatted; run 'caddy fmt --overwrite'" warning that fired on every FrankenPHP boot is gone.
### Added
- **Bundled-mode supervisor integration test** (`examples/todo/tests/bundled-supervisor.sh`, `make integration-bundled`). Stages a fake AppImage layout in `/tmp` (host binary copied — Qt's `applicationDirPath()` dereferences symlinks via `/proc/self/exe`, so the real layout has to be mimicked closely; staged Symfony tree is `chmod -R a-w` to actually exercise the read-only-mount cache redirect) and exercises the supervisor end-to-end without needing a real `.AppImage` build. Asserts `/healthz` deep-load + cache redirect. Wired into `.gitea/workflows/ci.yml` after the existing dev-mode integration test.
- **Skeleton AppImage parity.** `framework/skeleton/Makefile` gains `staging-symfony` + `appimage` targets mirroring `examples/todo/Makefile`'s. New `framework/skeleton/packaging/skeleton.{desktop,png}` provide minimal AppImage assembly inputs. `bin/php-qml-init` now: (a) renames packaging files to match the scaffolded app name, (b) rewrites the `.desktop` file's `Name`/`Exec`/`Icon`, (c) substitutes the new `BUNDLE_SRC` and `PACKAGING` Makefile variables to either absolute framework paths (default) or vendored `.bridge` / `.bridge-packaging` paths (`--vendor`). Scaffolded apps inherit `make appimage` working out of the box.
### Notes
-`BackendConnection::m_port` stays hardcoded to 8765 — port-collision between two installed php-qml apps is a real bug surfaced during v0.1.1 prep, but the fix touches every consumer that hardcodes 8765 (perfsmoke, the new bundled-supervisor test) so it's tracked as a v0.2.0 item rather than a v0.1.x bugfix.
## [0.1.0] — 2026-05-03
First public preview. Phases 0 through 4a in PLAN.md are complete plus the Phase 5 DX-polish sub-commits. Linux is the only packaged target; macOS and Windows are deferred to 4b / 4c. Tagging is the user's call (release CI runs on `v*` tags).
@@ -43,5 +61,6 @@ First public preview. Phases 0 through 4a in PLAN.md are complete plus the Phase
- The bundle ships without `composer.lock` (it's a library); the skeleton and the todo example carry their own.
- Licensed under **LGPL-3.0-or-later** (`LICENSE` + `LICENSE.GPL` at the repo root). Chosen to align with Qt 6's LGPLv3 licensing — see PLAN.md §12 for the relinkability obligations the AppImage build already honours.
# php-qml — Plan for a Symfony/FrankenPHP + Qt/QML Desktop Framework
> **Status (2026-05):** Phases 0–5 complete. v0.1.0 ready to tag — LGPL-3.0-or-later license shipped, repo URL fixed at `src.bundespruefstelle.ch/magdev/php-qml`. Tagging is user-driven.
> **Status (2026-05):** v0.1.0 shipped 2026-05-03 (LGPL-3.0-or-later). v0.1.1 ready to tag — closes the four shakedown follow-ups. Planning is version-based — see §13.
>
> **Where else to look:**
>
> - `docs/` — how to use what's built (architecture, getting-started, makers, packaging, API references).
> - `CHANGELOG.md` — what shipped in each release.
> - `PLAN.md` (this file) — *why* the architecture is the way it is, plus what's coming next per version.
## 1. Vision
@@ -509,336 +515,80 @@ Every dependency is version-pinned: Qt, the FrankenPHP binary URL with verified
| Telemetry / crash reporting | Diagnose issues in user environments. | Opt-in only. Sentry on the PHP side is straightforward; crash dumps from the Qt host are platform-specific and deferred. |
| Security model | Could the bundled FrankenPHP be tricked into binding to `0.0.0.0`? | Caddyfile is generated from a hard-coded template that binds to the unix socket / loopback; fail closed if env says otherwise. Audit before v1. |
## 13. Roadmap to POC
## 13. Versions
Phased, each phase ends with something runnable.
Phases 0–5 (the original POC roadmap) shipped as **v0.1.0** on 2026-05-03. From here on, work is organised by SemVer version rather than by phase:
### Phase 0 — Spike (throwaway)
- `v0.1.x` — bugfix releases (no new public API, no behaviour changes beyond the fix).
- `v0.x.0` — minor releases (new features, may break API in pre-1.0 — SemVer permits this).
- `v1.0.0` — when the public API is stable enough to commit to.
Hardcoded everything. Qt window spawns FrankenPHP, hits `GET /api/ping`, opens an SSE stream, prints incoming events to a `Text` element. Goal: prove the transport on Linux. ~1 day.
Pre-1.0 tags (`v0.*`) are marked **prerelease** in Gitea (`.gitea/workflows/release.yml`).
#### Concrete spec
Per-phase scope detail is preserved in `CHANGELOG.md` (per-version summary) and `git log` (per-commit detail) — no need to duplicate it here.
Lives in `spike/`. Removed when Phase 1's framework skeleton supersedes it. **No Symfony yet** — bare PHP behind FrankenPHP, the smallest thing that exercises both transport channels.
### v0.1.0 — shipped 2026-05-03
Layout:
First public preview. Linux AppImage. Full entry in `CHANGELOG.md`; binaries at <https://src.bundespruefstelle.ch/magdev/php-qml/releases/tag/v0.1.0>.
```text
spike/
README.md # how to run, what it proves, expected output
run.sh # builds (if needed) and runs FrankenPHP + the Qt host
Main.qml # window: status indicator, Ping button, event log
Mercure.qml # tiny SSE client (text/event-stream parser via QNetworkReply)
```
### v0.1.1 — bugfix release (ready to tag)
Flow:
All four shakedown follow-ups landed:
1. `./run.sh` builds the Qt binary (if not built) and runs it.
2. Qt host starts and spawns `bin/frankenphp run --config Caddyfile` as a child process.
3. Once `GET /api/ping` succeeds, QML opens an SSE connection to `/.well-known/mercure?topic=app://ping`.
4. Clicking the "Ping" button triggers `GET /api/ping`. The handler returns `{ "pong": true, "now": ... }` and publishes the same payload to Mercure.
5. The event arrives on the SSE stream and is appended to the visible log.
- **perfsmoke gap closed.** `HealthController` now constructor-injects `Publisher`; `/healthz` returns 200 only when `BridgeBundle` is fully container-resolvable. The `bundle` field in the response is the canary value perfsmoke + the bundled-mode integration test both check.
- **Bundled-mode supervisor integration test.** `examples/todo/tests/bundled-supervisor.sh` (run via `make integration-bundled`) stages a fake AppImage layout in `/tmp` and exercises the whole supervisor codepath (`resolveFrankenphpBin` → `runMigrations` → `spawnChild` → cache/log redirect to user data dir) without needing a real `.AppImage` build. Wired into ci.yml. Catches every v0.1.0 shakedown bug.
- **Skeleton AppImage parity.** `framework/skeleton/Makefile` gains `staging-symfony` + `appimage` targets mirroring the example's; `framework/skeleton/packaging/` ships starter `.desktop` + `.png`; `bin/php-qml-init` rewrites `BUNDLE_SRC` / `PACKAGING` Make variables and renames packaging files at scaffold time. `--vendor` mode also vendors `packaging/linux/` to `.bridge-packaging/`. Scaffolded apps inherit a working `make appimage` flow.
- **Caddyfile fmt.** `framework/skeleton/Caddyfile` and `examples/todo/Caddyfile` reformatted per `caddy fmt`; the "Caddyfile input is not formatted" boot warning is gone.
Hardcoded for the spike:
### v0.2.0 — next minor
- Backend URL: `http://127.0.0.1:8080`.
- Mercure topic: `app://ping`.
- Mercure JWT: dev-only static key in `.env.local`.
- No auth on `/api/ping`.
- FrankenPHP static binary version pinned in `run.sh`.
Pulls in the originally-Phase-3/5-deferred items that don't need new operational dependencies, plus the smaller §12 risks. Cross-platform packaging is parked at v0.9.0 — the operational lift (self-hosted runners + platform certs) is too big to fold into the next minor and Linux remains the primary target until the framework's surface stabilises.
Done criteria:
**Makers + reactive types (Phase 3.x deferred):**
- Click "Ping" → response text updates **and** an event line appears in the log within ~50 ms.
### Phase 1 — Framework skeleton (dev mode from day one)
- **`qmltestrunner`-driven QML unit tests.** Wires into the `quality` job alongside qmllint.
- **End-to-end UI test (Squish or Qt Test).** Was §12's deferral; bridge-integration covers IPC, this would catch UI-only regressions.
- `framework/php` Symfony bundle with `Publisher`, `HealthController`, `SessionAuthenticator`.
- `framework/qml` with `BackendConnection`, `RestClient`, `MercureClient`, and `SingleInstance`. `connectionState` is wired but the Update Semantics layer (§5) is stubbed (just `Connecting`/`Online`/`Error` for now).
- `BackendConnection` runs in **dev mode**: it reads a backend URL and bearer token from env / CLI flag instead of spawning a child. The developer runs FrankenPHP separately (`frankenphp run --watch` against the Symfony source).
- `symfony/maker-bundle` wired in as `require-dev`; `bridge:doctor` command implemented (§8) so first-run readiness errors are actionable.
- `skeleton/` ships a `Makefile` with `make dev`, boots an empty window, acquires the single-instance lock, and connects to that dev backend.
- `.gitea/workflows/ci.yml` runs the `quality` job (PHPStan, php-cs-fixer, qmllint, PHPUnit) from day one. Per-OS `build` jobs land in Phase 4.
- Goal: clone, `make dev`, edit code, see changes — no packaging in the way.
**Operations (§12):**
#### Detailed scope
- **Bundled-mode port negotiation.** `BackendConnection::m_port` is hardcoded to 8765 with no env override or negotiation, so two php-qml apps installed on the same machine collide on first launch (whichever loses the race goes Offline). Fix: bind a transient `QTcpServer` to `QHostAddress::LocalHost` port 0, grab `serverPort()`, hand it to FrankenPHP via the `PORT` env var. Needs a port-discovery mechanism for tests/perfsmoke that currently hardcode 8765 — likely write the chosen port to a sentinel file under the user data dir on supervisor activation. Surfaced from a v0.1.1 follow-up question; deferred to v0.2.0 because the test/consumer migration is wider than v0.1.x scope.
- **Pre-migration auto-backup** (§12, *Migrations on schema change*). Supervisor copies `var/data.sqlite` to `var/data.sqlite.<timestamp>.bak` before invoking `doctrine:migrations:migrate`; trims to N most recent.
- **`bridge:export` console command + UI hook** (§12, *Data backup / export*). Lets users copy their data out before machine moves or risky migrations.
- **Periodic auto-update check.** Phase 5 noted this as a polish item but didn't ship; v0.1.0 only has menu-triggered manual checks.
- **Build-time Symfony cache warmup** (§12, *Cold start*). Bake `var/cache/prod` into the AppImage so first launch skips warmup; first-launch supervisor copies it into the user data dir.
- **Native dialogs boundary doc.** §12 noted file pickers / notifications belong on the QML side via Qt — document the boundary and ship a small `Q_INVOKABLE` helper for the common cases.
Phase 1 turns the spike into the smallest dev-mode-only framework that can replace it. No bundled mode (Phase 4), no packaging, no auto-update.
### v0.3.0 — later minor
**Naming and identifiers (working, settable before any code):**
Bigger pieces still deferred (each warrants its own minor, not v0.2.0 noise):
| Qt minimum | `6.5 LTS` (build), `6.11` is what's on the dev box |
- **i18n bridge** (§12). Symfony Translator (XLIFF) + Qt Translator (.ts) with a shared locale switch fanning out to both.
- **Persistent log files + rotation** (Phase 5 out-of-scope). Symfony monolog wiring + a Qt-host log file with rotation. The dev console stays for live tails.
- **Multi-arch builds** (§12). Linux ARM64, Windows ARM, macOS universal (arm64 + x86_64). Each adds a CI matrix dimension.
- **Composer `create-project` package** (Phase 5 out-of-scope). Publish `php-qml/skeleton` as a composer template so `composer create-project php-qml/skeleton my-app` works. Bash `bin/php-qml-init` stays for curl-bootstrap.
Locks down the cross-platform story before promoting to v1.0.0. Held until v0.9.0 (rather than v0.2.0) because each item carries operational prerequisites (paid certs, self-hosted runners, platform-specific notarisation pipelines) that are easier to absorb in a single concentrated push than to drip-feed across minors. **Linux AppImage stays the only packaged target through v0.2.0/v0.3.0 and the v1.0.0 prep work** — alternate Linux channels (Flathub, Snap) and the macOS/Windows ports all land here.
**Sub-commits (each ends with something runnable):**
- **Flathub / Snap packaging** (§12). Alternate Linux channels for better discoverability than AppImage. Each adds its own packaging surface (Flatpak manifest + Flathub PR review; snapcraft.yaml + Snap Store listing).
- **Telemetry + crash reporting** (§12). Opt-in only, off by default; PHP-side Sentry for backend errors + a per-platform crash-dump pipeline for the Qt host (each OS does this differently — fits the cross-platform-packaging milestone). Plumbing settled before v1.0.0 even if no default endpoint ships.
1.**Repo restructure** — empty `framework/php`, `framework/qml`, `framework/skeleton`, `.gitea/workflows/ci.yml` stub. Update root `.gitignore`. Spike still in place.
6.**Skeleton wiring** — Symfony app + QML app + Makefile + Caddyfile. `make dev` opens a window connected to a separately-run FrankenPHP and visibly tracks `connectionState`. Replaces the spike functionally.
7.**CI quality job** — `.gitea/workflows/ci.yml` runs PHPStan (level 6 to start), php-cs-fixer (check mode), PHPUnit, `qmllint`. Workflow file exists even if a runner isn't provisioned yet.
8.**Retire the spike** — `spike/` deleted; key lessons already captured in PLAN.md and the framework code.
### v1.0.0 — when
**Update Semantics is stubbed**, not realised: `connectionState` flips between `Connecting` / `Online` / `Error` only. `Reconnecting`, `Offline`, `pending`-role rollback, command queue all arrive in Phase 2 with the reactive models.
When the public API (Symfony bundle services + attributes, Qt module C++ types + QML elements, maker output) is stable enough to commit to compatibility for. Items still in flux that should settle before this:
**Done criteria:**
- Fresh clone → `make dev` opens a window within ~3 s of FrankenPHP being ready, shows `Online`, displays a Mercure-pushed event when triggered.
- Killing the dev FrankenPHP → window flips to `Error`. Restart it → back to `Online`.
-Launching a second instance of the Qt host → first focuses, second exits.
-`bin/console bridge:doctor` flags missing config with actionable messages.
- CI's `quality` job runs (green when clean, red on real issues, not on misconfiguration).
-`spike/` is gone.
### Phase 2 — Reactive models, update semantics, and the headline maker
-`ReactiveListModel`, `ReactiveObject` on the QML side, with `pending` role and pagination.
-`ModelPublisher` + Doctrine listener on the PHP side, including `correlationKey` plumbing in the envelope.
- Update Semantics layer fully realised: optimistic mutations, rollback on error/timeout, `connectionState` transitions, `Reconnecting` + `Offline` UI in `AppShell`.
- Convention test: run `bin/console make:bridge:resource Todo`, then `make:migration` and `doctrine:migrations:migrate`; verify a QML `ListView` updates on backend changes triggered from a CLI command. No handwritten glue between the two sides.
#### Phase 2 detailed scope
Phase 2 turns the framework from "transports work" into "you can ship a reactive list-of-X with three commands". After this phase, the smallest working bridge app is `make:bridge:resource Foo && make:migration && doctrine:migrations:migrate` plus a `<Foo>List.qml` snippet — and the list updates live as `Foo` rows change.
1.**Doctrine + migrations into the skeleton.**`composer require doctrine/orm doctrine/doctrine-bundle doctrine/doctrine-migrations-bundle`, generate `config/packages/doctrine.yaml` and `doctrine_migrations.yaml`, point the dev DB at `var/data.sqlite`. `bridge:doctor` gains a `database reachable` check. `make doctor` is green on a fresh clone after `make install` + `bin/console doctrine:migrations:migrate`.
2.**`ModelPublisher` (PHP) + Doctrine subscriber.** New service in `framework/php/src/`: takes a Doctrine entity + change op + correlation key, computes the envelope and dual-publishes to `app://model/{name}` (collection topic) and `app://model/{name}/{id}` (entity topic). The subscriber introspects entities tagged with `#[BridgeResource]` and routes lifecycle events through `ModelPublisher`. PHPUnit covers the envelope shape, dual publish, and correlation-key passthrough.
3.**Reactive models + full Update Semantics (QML).**`ReactiveListModel` (`QAbstractListModel` + topic subscription + initial fetch + cursor-driven `fetchMore` + `pending` role + diff application). `ReactiveObject` (single-entity equivalent). `BackendConnection`'s enum extended to `Connecting / Online / Reconnecting / Offline` with thresholds (PLAN.md §5). `AppShell.qml` ships a `Reconnecting` top banner and `Offline` overlay with retry. Optimistic command wiring: `RestClient.invoke()` returns a Promise that resolves on the matching Mercure echo (correlation-key-matched), rolls back on `4xx`/`5xx` or timeout (default 10s).
4.**`make:bridge:resource` maker.** `symfony/maker-bundle` becomes a `require-dev` of the bundle. `BridgeResourceMaker` generates: `src/Entity/<Name>.php` (`#[BridgeResource]` attribute, `id` + `title` stub fields), `src/Controller/<Name>Controller.php` (CRUD on `/api/<name>`), and `qml/<Name>List.qml` (a starter `ListView` bound to a `ReactiveListModel`). After-hint points at `make:migration`. Lifecycle wiring is automatic (the subscriber from sub-commit 2 handles any `#[BridgeResource]` entity), so no per-resource listener is generated. The maker output is checked into the skeleton as a regression reference for Phase 3's CI snapshot test.
5.**Convention test + phase closure.** Run the maker against a `Todo` resource, run migrations, trigger inserts/updates/deletes via `bin/console` (a one-liner) and confirm the skeleton's QML window shows the list updating live, with row-level `pending` rendering during the brief in-flight window. Capture a short `framework/skeleton/README.md` walkthrough so future readers can reproduce.
**Done criteria:**
-`make:bridge:resource Todo` plus `make:migration` plus `doctrine:migrations:migrate` produces a working reactive list with no handwritten bridge glue.
- Triggering CRUD via `bin/console` updates the QML `ListView` within ~50 ms of the SQL commit.
- Killing FrankenPHP mid-mutation: `connectionState` transitions to `Reconnecting` then `Offline`; the optimistic row stays `pending` until rollback fires; reconnect re-fetches and clears.
-`make quality` stays green (PHPStan, cs-fixer, PHPUnit, qmllint).
- The skeleton's checked-in maker output is byte-for-byte the same as a fresh maker run, so Phase 3's CI snapshot test has a baseline.
### Phase 3 — POC application, testing infrastructure (built via the makers)
- Build `examples/todo` by running the makers — `make:bridge:resource Todo`, `make:bridge:command MarkAllDone`, `make:bridge:window TodoWindow`. The example doubles as a maker-output regression test (CI diffs generator output against a checked-in reference).
- Implement remaining makers (`command`, `event`, `read-model`, `window`) as needed by the example.
- Stand up testing infrastructure: `qmltestrunner` for QML unit tests, plus a thin bridge-integration suite that boots the host + child and exercises the IPC stack end-to-end. Both wired into the `quality` CI job.
- Multi-window test passes.
- Crash-and-recover test passes (covers `tokenRotated` and `Reconnecting` → `Online` recovery).
#### Phase 3 detailed scope
Phase 3 turns the framework from "the smallest reactive resource" into "a real application that exercises every architectural primitive". The POC todo app becomes the artefact a sceptical reader can clone, run, and use to evaluate the framework.
**Maker scope:**
| Maker | Status |
| --- | --- |
| `make:bridge:resource` | shipped (Phase 2) |
| `make:bridge:command` | **shipped in Phase 3** — todo app uses it for "mark all done" |
| `make:bridge:window` | **shipped in Phase 3** — todo app uses it for the second window |
| `make:bridge:event` | **deferred** — not required by the todo app; Phase 3.x or beyond |
| `make:bridge:read-model` | **deferred** — same |
**Sub-commits (each ends runnable):**
1.**`ReactiveObject` C++ type.** Single-entity twin of `ReactiveListModel` with the same envelope handling, a `pending` indicator on the bound properties, and an optimistic `invoke()`. The todo app's edit form binds to it; opening "the same todo" in a second window shows in-flight changes converging.
2.**`make:bridge:window` + `make:bridge:command` makers.** Window maker generates `<Name>Window.qml` using `AppShell` boilerplate and registers it with a small window registry on the C++ host so it's openable from menus or single-instance launch-arg dispatch (PLAN.md §3, §6, §7). Command maker generates a Messenger command + handler + controller route on the PHP side and a QML helper on the bridge module. Templates excluded from PHPStan / cs-fixer the same way the resource maker's are.
3.**`examples/todo` app — built via the makers.** Standalone Composer/CMake project under `examples/todo/` derived from the skeleton with:
-`Todo` resource generated via `make:bridge:resource`,
-`MarkAllDone` command generated via `make:bridge:command`,
- Main window with a list, add input, toggle/delete actions, and an "open second window" menu item,
- Second window scaffolded via `make:bridge:window`, sharing the same `ReactiveListModel` so both windows update live.
No handwritten glue between PHP and QML — every cross-side wire is maker-generated. Verifies the convention test from Phase 2 holds for a non-trivial app.
4.**Multi-window + crash-and-recover tests.** Bridge-integration test that boots a real FrankenPHP child plus an offscreen Qt host (CI-friendly, headless) and:
- Triggers a CRUD round-trip; asserts the QML model reflects it within 100 ms.
- Opens a second window; asserts both models converge.
- Kills the FrankenPHP child mid-test; asserts `connectionState` transitions Online → Reconnecting → Online on restart with no model corruption.
Plus a `qmltestrunner` smoke test for `RestClient.qml` and `AppShell.qml` so QML-side unit tests have a place to grow. CI's `quality` job invokes both.
5.**Maker-output snapshot test + phase closure.** CI step that re-runs `make:bridge:resource Todo`, `make:bridge:command MarkAllDone`, `make:bridge:window TodoWindow` against a clean copy of the skeleton and `git diff --exit-code`s against the checked-in baseline. Catches silent generator drift. PLAN.md updated; `examples/todo`'s README documents the multi-window and crash-recovery procedures so a human can reproduce them too.
**Deferred to Phase 3.x or Phase 4:**
-`ReactiveObject` cursor pagination (the resource has too few rows to need it).
-`make:bridge:event` and `make:bridge:read-model` — no use case in the todo app yet.
- A full Squish / Qt Test end-to-end suite — out of scope; the bridge-integration test is the floor.
**Done criteria:**
-`examples/todo` is buildable (`make build`) and runnable (`make dev`) standalone.
- Two windows of the same app stay in sync within 100 ms.
- Killing FrankenPHP visibly flips both windows to `Reconnecting` / `Offline`; restart restores `Online` and re-fetches without dupes.
-`make quality` runs all Phase-2 checks plus the bridge-integration test, the qmltestrunner suite, and the maker-output snapshot test.
-`make:bridge:command` and `make:bridge:window` ship with the same template / quality-tooling exclusions as `make:bridge:resource`.
- Add bundled-mode startup to `BackendConnection`: spawn the embedded `frankenphp`, generate per-session secret, run first-launch migrations.
- Linux AppImage first (simplest), then macOS, then Windows.
- Extend `.gitea/workflows/ci.yml` with the per-OS `build` matrix. Add `.gitea/workflows/release.yml` for `v*` tags: signing, `SHA256SUMS`, Gitea Release upload, and the auto-update appcast (`latest.json`).
- Wire the per-platform updaters (AppImageUpdate, Sparkle 2, WinSparkle) into the host so a built binary actually updates itself end-to-end.
- Stand up the performance-smoke harness in CI, asserting the §11 budgets on every release build.
- Provision the macOS self-hosted runner before this phase starts — it gates the macOS build.
- Document the build pipeline and the runner topology.
#### Phase 4 detailed scope
Phase 4 is genuinely big — bundled-mode startup is a host-architecture change, and the per-OS packaging trifecta carries operational dependencies (Apple Developer cert + notarization for macOS, Authenticode + a Windows runner for Windows) that can't be solved from a Linux dev machine. **Phase 4 is split into three sub-phases — only 4a (Linux) ships now**; 4b (macOS) and 4c (Windows) wait until their runners and credentials exist.
**Sub-phase split:**
| Sub-phase | Platform | Hard prerequisites |
| --- | --- | --- |
| **4a** | Linux AppImage + bundled mode + Linux release CI + AppImageUpdate + perf-smoke harness | none (covered in this dev environment) |
| **4c** | Windows NSIS + WinSparkle + Authenticode | self-hosted Windows runner, code-signing cert |
Sub-phases 4b and 4c are scoped in their own `Phase 4b` / `Phase 4c` entries in this section once their prerequisites are met. The framework code stays portable — bundled-mode plumbing in 4a is platform-agnostic, only the packaging layer is platform-specific.
**Phase 4a sub-commits (each ends runnable):**
1.**Bundled-mode startup in `BackendConnection`.** Mode is auto-detected: `BRIDGE_URL` env set → dev mode (today's behaviour). `BRIDGE_URL` unset → bundled mode, where the host:
- resolves the user app data dir per OS (`$XDG_DATA_HOME/php-qml-app` on Linux),
- generates a per-session 32-byte random secret and writes it to a child-process env var,
- spawns `bin/frankenphp` next to the host binary (overridable via `BRIDGE_FRANKENPHP_BIN`),
- waits for `/healthz`,
- on first-ever launch, runs `bin/console doctrine:migrations:migrate -n` against the user's DB before opening the SSE connection.
The token-rotation signal already wired in §3 *Edge cases* fires when the supervisor restarts the child mid-session; subsequent commits exercise it. Skeleton + example pick up bundled mode by default with no config when run outside dev mode.
2.**AppImage recipe.**`packaging/linux/build-appimage.sh` script that produces a single-file `.AppImage`:
-`cmake --install` the host into a staging dir,
- copy the bundled `frankenphp` binary + the Symfony app tree (`composer install --no-dev`) into the AppDir,
- run `linuxdeployqt` to gather Qt runtime,
- run `appimagetool` to seal it.
The example app gets a target `make appimage` that invokes the script with the example's bits. Hard-coded versions for `linuxdeployqt` and `appimagetool` (downloaded into a tools dir, gitignored).
3.**Linux release CI.**`.gitea/workflows/release.yml` triggered by `v*` tags. Matrix initially has only Linux (macOS/Windows added when their sub-phases land). Builds the AppImage, signs `SHA256SUMS` with the GPG release key, uploads everything to a Gitea Release. CI's `quality` workflow stays as-is.
4.**AppImageUpdate + appcast.**`latest.json` published alongside the release, describing the version + URL + sha256. The host links against `libappimageupdate` and exposes `BackendConnection.checkForUpdates()` (no-op in dev mode). User triggers manually via menu (Phase 5 will polish to a periodic check).
5.**Performance-smoke harness + phase closure.** A CI job that runs the example app's bundled binary headlessly (offscreen QPA), asserts cold-start ≤ 2 s, idle memory ≤ 200 MB, list-render ≤ 250 ms (PLAN.md §11). Numbers reported per-build. PLAN.md updated to mark 4a closed.
**Out of scope for 4a (deferred to 4b / 4c / Phase 5):**
-`qmltestrunner`-driven QML unit tests — Phase 3.x or Phase 5.
**Done criteria for 4a:**
-`make appimage` produces a runnable single-file `.AppImage` of the todo example.
- The AppImage launches without any `BRIDGE_URL` configured, spawns its embedded FrankenPHP, runs first-launch migrations into `~/.local/share/php-qml-todo/var/data.sqlite`, and shows the todo UI.
- Killing the bundled FrankenPHP from outside the AppImage triggers the supervisor restart in `BackendConnection`; `tokenRotated` fires; the QML side recovers.
- A `v*` tag pushes a Linux AppImage + signed `SHA256SUMS` + appcast to a Gitea Release.
-`BackendConnection.checkForUpdates()` invoked from the menu finds a newer release and updates in place.
- The performance-smoke harness reports cold-start / memory / render-time numbers within budget on every release build.
**4a status: closed (commits a1cc06a → 4a-sub-5).** Ship-readiness on Linux. macOS (4b) and Windows (4c) remain stubs in this section; their entries get filled in once self-hosted runners and platform certs land.
### Phase 5 — DX polish
- Project skeleton via Composer / a small CLI to scaffold a new app.
- Hot-reload story documented end-to-end (PHP via FrankenPHP `--watch`, QML via Qt tooling).
#### Phase 5 detailed scope
Phase 5 is genuinely smaller than 4a — closes out outstanding DX seams that PLAN.md §8 promised: child-process log surface, scaffolding for a fresh app, hot-reload story, IDE configs. Then a release-readiness pass so a v0.1.0 tag is plausible.
**Sub-commits (each ends runnable):**
1.**Child-output capture + dev console.**`BackendConnection` switches FrankenPHP's `processChannelMode` to merged + readable, surfaces lines via a new `childLogLine(line, level)` signal, and keeps a small ring buffer (~500 lines) accessible via `Q_INVOKABLE childLogTail()`. Ships `DevConsole.qml` — an optional `Item` (apps drop it in via `Loader { source: ... }`) that displays the tail with auto-scroll. Skeleton + example get a `Ctrl+`` (back-tick) keybinding to toggle the console.
2.**Project init script.**`bin/php-qml-init <name>` (a single bash script, no system-wide install required): copies `framework/skeleton` into `<name>/`, rewrites the path-repo to point at the user's chosen bundle location (vendored copy or absolute path), runs `composer install` and the migrations, and prints the next-step hints. Lives at the repo root so curl-based bootstrap works (`curl … | bash -s -- my-app`).
3.**Hot-reload docs + editor configs.** Documented in `framework/skeleton/README.md`: PHP-side via `frankenphp run --watch` (already what `make dev` uses), QML-side via Qt Creator's *Reload* / `qmlls` live preview / running QML from source rather than baked resources. Skeleton (and example, mirroring) ship `.vscode/launch.json` (Xdebug-into-FrankenPHP attach config + Qt host launch config) and a minimal `.idea/runConfigurations/` set so PhpStorm / Qt Creator users start with a working debugger.
4.**Release-readiness pass + v0.1.0 prep.** Root `README.md` updated to reflect the actual onboarding (clone → `php-qml-init` → `make dev` / `make appimage`). `CHANGELOG.md` created at the repo root following Keep-a-Changelog conventions, with `v0.1.0` entry summarising Phases 0-4a. PLAN.md gets a small "Status" line near the top noting current phase. **Tagging itself stays user-driven** — per the release-process memory, tagging triggers `release.yml`, which I won't pull unilaterally.
**Out of scope for Phase 5:**
- A real `composer create-project` package — would require publishing `php-qml/skeleton` as a Composer package, which is overkill for a single-org project. Bash-script init covers the same UX.
- Native log files / log rotation — the dev console is in-memory only. Apps that need persistent logs configure Symfony's monolog as usual; the bundled FrankenPHP already writes to `var/log/`.
- 4b / 4c (macOS / Windows) — same as Phase 4a's deferral.
**Done criteria:**
-`bin/php-qml-init my-app` from a fresh clone produces a working dev environment that `make dev` boots.
- Toggling the dev console in the example shows live FrankenPHP child output.
- README walks a newcomer end-to-end without reading PLAN.md.
- CHANGELOG.md records a v0.1.0 entry; tagging is the user's call.
-`make quality` stays green throughout.
**Phase 5 status: closed (commits 4c15ac2 → a3d35a7).** All four planned sub-commits landed plus an unplanned `docs/` rewrite (`da04843`) lifting long-form material out of the README into ten topic guides, then a closure commit (`a3d35a7`). The two release-prep items previously listed here — LICENSE selection and Gitea-host URL substitution — were resolved in a follow-up release-prep commit: project is **LGPL-3.0-or-later** (chosen to align with Qt 6's LGPLv3, satisfying the relinkability obligation in §12), with `LICENSE` (LGPL-3.0 text) and `LICENSE.GPL` (GPL-3.0 text the LGPL incorporates) at the repo root and `framework/php/composer.json` updated; placeholder URLs replaced with `src.bundespruefstelle.ch/magdev/php-qml` in CHANGELOG, README, `docs/getting-started.md`, `docs/packaging-linux.md`. Only the CHANGELOG `[0.1.0] — TBD` release date stays unfilled; per the release-process memory, user updates that on tag push.
After Phase 4 the POC is complete and the architecture is validated on a real packaged binary. Phase 5 is what turns it into something other people would actually adopt.
- **Auth model** (§12). Per-session bearer is fine for local-only; revisit if Mercure ever leaves loopback.
- **Mercure storage strategy.** In-memory works for bundled mode now; document or switch if persistence is needed.
- **AppImage relinkability** (§12, Qt LGPL row). Document and test the user-side relink procedure end-to-end.
- **Security model audit** (§12). Caddyfile generation hardened against `0.0.0.0` binding; loopback-only enforcement audited end-to-end.
-**FrankenPHP-as-library evaluation** (§12 — future optimisation). CGo-embed FrankenPHP into the Qt host as a single process. Subprocess model stays the default; this is a perf optimisation only if measurements warrant.
@@ -61,6 +61,7 @@ bin/console make:bridge:resource Todo --int-id
```
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.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.