Files
php-qml/PLAN.md
magdev ccd2f1b27c Detail Phase 4 scope; split into 4a (Linux now) / 4b (macOS) / 4c (Windows)
Honest scoping: macOS and Windows packaging carry hard operational
prerequisites (self-hosted runners, Apple Developer + Authenticode certs)
that can't be solved from a Linux dev machine. Phase 4a delivers the full
Linux pipeline now; 4b and 4c wait until those prerequisites land.

4a sub-commits:
  1. Bundled-mode startup (auto-detected: no BRIDGE_URL → spawn child,
     per-session secret, first-launch migrations into XDG data dir)
  2. AppImage recipe (packaging/linux/build-appimage.sh + make appimage)
  3. Linux release.yml on v* tags (Gitea Release + SHA256SUMS + appcast)
  4. AppImageUpdate + BackendConnection.checkForUpdates()
  5. Performance-smoke harness + 4a phase closure

The framework code stays platform-agnostic — only the packaging layer
is per-OS. 4b / 4c entries get filled into PLAN.md when their runners
and certs become available.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 16:32:20 +02:00

56 KiB
Raw Blame History

php-qml — Plan for a Symfony/FrankenPHP + Qt/QML Desktop Framework

1. Vision

A small, opinionated framework that lets PHP developers ship native desktop applications with a Qt/QML frontend and a modern Symfony backend, packaged as a single distributable per OS.

The framework is not a PHP↔Qt language binding. It is a process-pair architecture:

  • A Qt/QML host process owns the window, input, and rendering.
  • A bundled FrankenPHP binary runs a Symfony application in worker mode as a child process.
  • The two communicate over local HTTP (commands/queries) and Mercure SSE (push/state sync).

The framework's job is to make that boring: lifecycle, transport, conventions, and reactive models — so an application author writes ordinary Symfony code and ordinary QML, and they just connect.

2. Architecture Overview

+-----------------------------------------------------------+
|                  Single OS-native bundle                  |
|                                                           |
|  +----------------------+      +-----------------------+  |
|  |   Qt/QML host (C++)  |      |  FrankenPHP (child)   |  |
|  |                      |      |                       |  |
|  |  - QGuiApplication   |      |  - Caddy + Mercure    |  |
|  |  - QML engine        |      |  - PHP worker mode    |  |
|  |  - BackendConnection |<---->|  - Symfony kernel     |  |
|  |  - SSE client        | HTTP |     (boots once)      |  |
|  |  - Reactive models   | SSE  |  - Doctrine + SQLite  |  |
|  +----------------------+      +-----------------------+  |
|                                                           |
+-----------------------------------------------------------+
        \_____________ one binary to ship _________________/

Transport choice:

  • Linux / macOS: Unix domain socket (preferred — no port allocation, no firewall prompts).
  • Windows: loopback TCP on a random ephemeral port, bound to 127.0.0.1.

3. Process Lifecycle

Run modes

The host supports two run modes, selected at build time and surfaced to the rest of the system through the same BackendConnection API:

  • Bundled (default for shipped binaries) — host spawns the embedded frankenphp child as described in Startup below.
  • Dev — host does not spawn anything. It connects to a developer-managed backend at a URL given via env var or CLI flag, typically frankenphp run --watch or symfony serve running from the Symfony source tree. PHP code changes are picked up by the watcher; QML changes are picked up by Qt's own live-reload tooling (Qt Creator, qmlls, or running QML from source rather than from the compiled qrc:).

The dev/bundled split is a first-class concern, not a Phase-5 nicety: the framework skeleton must boot in dev mode from Phase 1 onward (§13). Bundled mode is the additional thing introduced when packaging arrives.

In dev mode, auth is simplified — the bearer token is read from a shared .env.local rather than generated per-session, so the developer can curl the API freely.

Single-instance

The host is single-instance per OS user. On launch, before anything else:

  1. Acquire the single-instance lock by binding a named local endpoint — QLocalServer on a fixed-name socket on Unix, a named pipe on Windows. The endpoint name is derived from the application identifier and the current user.
  2. If the bind fails, an instance is already running. Connect to that endpoint, forward the launch arguments (e.g. open <path>, --new-window, deep-link URI), then exit 0.
  3. Otherwise, this process becomes the live instance and continues to Startup. The bound endpoint stays open for the lifetime of the process and accepts launch-arg messages from subsequent invocations; each message is dispatched to the application (default action: focus the existing main window, or open a new one if --new-window was passed).

The single-instance lock and the backend transport are separate endpoints — do not conflate them. The lock lives at a stable, predictable path; the backend socket is per-session and ephemeral.

Startup

  1. Resolve the user's app data directory (per OS convention) and ensure var/, var/data.sqlite, var/cache/, var/log/ exist.
  2. Generate a per-session shared secret (random 32 bytes) and write it to an env var passed to the child. (Dev mode skips this; the secret comes from .env.local.)
  3. Bundled mode only: spawn the bundled frankenphp binary with a generated Caddyfile pointing at the embedded Symfony public/index.php.
  4. Wait for a readiness signal (HTTP GET /healthz returns 200, with bounded retry / timeout). In dev mode the host waits on the configured URL; in bundled mode, on the freshly spawned child.
  5. QML root window loads, opens its Mercure subscription, fetches initial state.

Steady state

  • Symfony kernel persists across requests (worker mode); Doctrine connection pooled.
  • Mercure hub is in-process inside FrankenPHP — no separate daemon.
  • The single-instance endpoint accepts and dispatches launch-arg messages from new invocations.

Shutdown

  • Bundled mode: host sends SIGTERM to the child; on timeout, SIGKILL. Host removes the unix socket file (Linux/macOS).
  • Dev mode: host disconnects; the dev backend keeps running.
  • The single-instance lock is released as the host process exits.
  • A crash supervisor restarts the bundled child up to N times; after that, the host shows a fatal-error UI. Dev mode does not supervise the backend — that's the developer's terminal.

Edge cases

  • macOS launch dispatch. File-open and URL-handler events on macOS arrive via Apple Events (QFileOpenEvent), not argv. The single-instance dispatcher must subscribe to that event in addition to the named-endpoint launch-arg channel; otherwise double-clicking a file or following a myapp:// URL silently does nothing.
  • Sleep / wake. Closing a laptop lid drops the SSE connection. Reconnect uses Last-Event-ID, but if the wake happens after the Mercure hub has rolled past that ID, clients get an explicit gap signal and re-issue initial fetches (§5, Connection state).
  • Per-session secret rotation. When the crash supervisor restarts the bundled child, a new shared secret is generated. The host updates BackendConnection.token in place and emits tokenRotated; MercureClient and RestClient pick up the new value on next request. In-flight requests with the old token are retried.
  • Single-instance launch race. If two instances launch in the same millisecond, both can fail to bind. The bind is retried briefly with a short backoff before the host gives up and connects as the second instance — preventing a deadlock where both processes exit thinking the other one won.

4. Communication Contract

Commands and queries (QML → PHP)

  • Plain HTTP, JSON in/out.
  • Conventional routes:
    • GET /api/{resource} — list / read
    • POST /api/{resource} — create / invoke command
    • PATCH /api/{resource}/{id} — update
    • DELETE /api/{resource}/{id} — delete
  • Authentication: per-session shared secret in Authorization: Bearer … header, validated by a Symfony firewall.
  • Errors: RFC 7807 application/problem+json.

Push (PHP → QML) via Mercure

  • Topic convention:

    • app://model/{name} — collection-level changes
    • app://model/{name}/{id} — single-entity changes
    • app://event/{domainEvent} — arbitrary domain events
  • Update envelope:

    {
      "op": "upsert" | "delete" | "replace" | "event",
      "id": "...",
      "data": { ... },
      "version": 42,
      "correlationKey": "optional-uuid-from-originating-request"
    }
    
  • version is monotonic per topic, used by clients to detect gaps and trigger a re-fetch.

  • correlationKey echoes the Idempotency-Key of the request that caused this update, when applicable. Clients use it to match server-pushed updates back to their pending optimistic mutations (§5).

Why this split

  • HTTP for commands keeps semantics obvious (status codes, idempotency keys, file uploads later).
  • SSE for push is one-way which matches the actual data-flow direction. We avoid bidirectional WebSocket state machines for the POC.

Pagination

Collections larger than a few hundred entries need cursor-based pagination, not full-table fetches.

  • GET /api/{resource}?cursor={opaque}&limit=50 returns { items, nextCursor }. The cursor is server-generated and opaque to the client.
  • ReactiveListModel requests pages on demand (initial fetch plus scroll-driven fetch-more) and integrates Mercure-pushed inserts at the head.
  • Mercure topics for paginated resources publish single-row diffs only; no "the whole list changed" envelope. Clients needing a full reset issue a new initial fetch.

File uploads

  • POST /api/{resource} accepts multipart/form-data for resources with file fields.
  • The controller streams uploads via Symfony's UploadedFile API into FrankenPHP's upload_tmp_dir, then moves the validated file to var/uploads/{resource}/{id}/... under the user data dir.
  • Long uploads emit progress envelopes to a per-upload Mercure topic so QML renders a progress bar without polling.
  • Maximum upload size is configured per resource and surfaced via OPTIONS /api/{resource} so the client can pre-check.

Idempotency

  • Mutating routes (POST, PATCH, DELETE) accept an optional Idempotency-Key header carrying a client-generated UUID.
  • Symfony stores the (key, response) tuple for 24 h in a Doctrine-backed cache; replays return the cached response without re-running the handler.
  • RestClient generates and attaches keys automatically for non-GET requests so retries after a transient SSE drop or app relaunch are safe by default.

5. Update Semantics and Disconnected UX

How state changes flow between QML and PHP, and what the UI does when the backend is slow, restarting, or unreachable. These are framework decisions, not application choices, because the reactive models embed them.

Optimistic updates

When QML invokes a mutating command, the framework applies the change locally first and reconciles with the server-pushed Mercure echo:

  • ReactiveListModel exposes a pending role per row; delegates render in-flight rows differently (reduced opacity, inline spinner).
  • Each command carries an Idempotency-Key (§4); the Mercure echo carries the same value as correlationKey so the model can match the push back to the originating optimistic mutation and clear pending.
  • On 4xx / 5xx response, the model rolls back the optimistic mutation and emits commandFailed(error) for the application to surface as a toast or inline error.
  • Timeout: if no Mercure echo arrives within N seconds (default 10 s), the model rolls back and emits commandTimedOut — usually because the backend died mid-command.

Connection state

BackendConnection exposes connection health as a first-class property:

  • connectionState enum: Connecting, Online, Reconnecting, Offline.
  • OnlineReconnecting after one SSE drop and one failed reconnect. Subsequent failures stay at Reconnecting with exponential backoff.
  • ReconnectingOffline after a configurable threshold (default 30 s of failed reconnects).
  • On recovery, ReactiveListModels issue a fresh initial fetch (the SSE buffer may have rolled past Last-Event-ID), and queued commands are flushed in order. Idempotency keys make the flush safe.

Disconnected behaviour

  • In Reconnecting, optimistic mutations still apply locally but their Mercure echoes are deferred — the UI shows a soft "syncing" indicator without blocking input.
  • In Offline, queued commands are paused (not failed). The application can either keep accepting writes (queued for flush) or render a read-only state — the framework exposes both options as a BackendConnection.offlineWritePolicy setting.
  • AppShell.qml provides default UI for Reconnecting (top banner) and Offline (full-screen overlay with retry button). Applications can override by binding to connectionState directly.

6. PHP-side Components (Symfony bundle)

Working name: PhpQml\Bridge.

  • BridgeBundle — Symfony bundle, autoconfigures the rest.
  • SessionAuthenticator — validates the per-session bearer token from env.
  • Publisher — thin wrapper over symfony/mercure's HubInterface, enforces topic naming and envelope shape.
  • ModelPublisher — given a Doctrine entity + change type, publishes the right app://model/... update. Wired via Doctrine lifecycle listeners or Messenger.
  • EventBridge — Symfony event subscriber that converts selected domain events into app://event/... updates.
  • HealthController/healthz for the host's readiness probe.
  • AppPaths — service that reads OS-appropriate paths (passed in via env from the host) so kernel, cache, logs, and SQLite all live in the user's data dir, not next to the binary.
  • Recipe / skeleton — a Composer-installable starter that wires framework, doctrine, mercure, runtime/frankenphp-symfony, and this bundle, with a sane default Caddyfile.

Configuration source-of-truth is Symfony YAML, not QML — backend is the model, frontend is the view.

7. QML-side Components (Qt module)

Working name: PhpQml.Bridge QML module + a small C++ plugin where required.

  • BackendConnection (C++ singleton, exposed to QML)

    • Owns the backend lifecycle: in bundled mode, the child process handle; in dev mode, just the configured URL.
    • Exposes ready, error, mode ("bundled" | "dev"), restart() (no-op in dev mode).
    • Holds the session token and injects it into requests; emits tokenRotated when the bundled child restarts and the secret changes (§3, Edge cases).
    • Exposes connectionState (Connecting / Online / Reconnecting / Offline) and offlineWritePolicy (queue | readOnly); these drive the Update Semantics layer (§5).
  • SingleInstance (C++ singleton)

    • Owns the named-endpoint lock described in §3.
    • Emits launchArgsReceived(args) when a second invocation forwards its arguments.
    • Application code subscribes to that signal to decide whether to focus the existing window, open a new one, or handle a deep link.
  • RestClient (QML/JS)

    • Promise-style get/post/patch/del. Built on QNetworkAccessManager.
    • Centralised error mapping (problem+json → JS error objects).
    • Auto-attaches a freshly generated Idempotency-Key to every non-GET request (§4) so retries — manual or via the Update Semantics queue — are safe by default.
  • MercureClient (C++)

    • Implements the text/event-stream protocol against QNetworkReply.
    • Reconnects with exponential backoff and Last-Event-ID resume.
    • Emits update(topic, envelope) signals into QML.
  • ReactiveListModel (C++, extends QAbstractListModel)

    • Constructed with a topic and an initial-fetch URL.
    • On creation: GET the URL (paginated, §4), populate; subscribe to the topic; apply upsert/delete/replace ops with role-name mapping derived from the first row. Scroll-driven fetchMore() pulls subsequent pages.
    • Detects version gaps → triggers a refetch.
    • Exposes a pending role per row for optimistic updates (§5); cleared when the matching Mercure echo arrives, rolled back on commandFailed / commandTimedOut.
  • ReactiveObject (C++)

    • Single-entity equivalent of ReactiveListModel. Properties become QML-bindable.
  • AppShell.qml

    • Optional convenience root component: renders a splash while BackendConnection is starting, swaps to the user's root component on ready, shows the fatal-error UI on terminal failure.
    • Renders default UI for the disconnected states defined in §5: a top banner during Reconnecting, a full-screen overlay with a retry button during Offline. Applications can override by binding to connectionState directly.

The application author should be able to write:

ListView {
    model: ReactiveListModel {
        topic: "app://model/todos"
        source: "/api/todos"
    }
    delegate: TodoItem { ... }
}

…and have it Just Work.

8. Developer Experience and Scaffolding

DX is part of the design surface, not a Phase-5 polish item. We lean on symfony/maker-bundle to scaffold code interactively, ship our own bridge-aware makers, and provide inspection commands to manage a running application.

Why maker-bundle

Maker-bundle is the canonical Symfony scaffolding tool — interactive, idiomatic, familiar to every Symfony developer, and pluggable. Building on it rather than inventing a parallel CLI means we:

  • avoid teaching a second mental model,
  • inherit make:entity, make:controller, make:command, make:migration, make:test, etc. for free,
  • compose cleanly: a developer can run make:entity Todo and then make:bridge:resource Todo to wire that entity through to QML.

symfony/maker-bundle is a require-dev dependency of the skeleton from Phase 1.

Custom makers (make:bridge:*)

All under the make:bridge:* namespace, runnable via bin/console. Each writes to both the PHP side and the configured QML source directory (framework.bridge.qml_path in config/packages/php_qml.yaml, default ../qml/). After-generation hints suggest the next logical command.

  • make:bridge:resource <Name> — the headline maker. Composes make:entity plus:

    • Generates a CRUD ApiController mounted at /api/{name}.
    • Registers a Doctrine lifecycle listener so persist/update/remove publish to app://model/{name}.
    • Emits a starter <Name>List.qml snippet that binds a ReactiveListModel to the resource.
    • After-hint: run make:migration.
  • make:bridge:command <Name> — non-CRUD actions (e.g. MarkAllRead, ImportCsv).

    • Generates a Messenger command + handler.
    • Generates a controller route that dispatches it.
    • Adds a helper to the QML bridge module so the call site reads as Bridge.markAllRead(...).
  • make:bridge:event <Name> — domain event plus its bridge wiring.

    • Generates the event class and a subscriber that republishes it on app://event/{name}.
    • Generates a QML signal-handler stub.
  • make:bridge:read-model <Name> — query-only views (no entity, no writes).

    • Generates a controller + a query service stub.
    • Generates a ReactiveListModel-bound QML component.
  • make:bridge:window <Name> — top-level QML window scaffold.

    • Generates <Name>Window.qml using AppShell boilerplate.
    • Registers it with the host's window registry so it's openable from menus or from launch-arg dispatch (§3, single-instance).

Lifecycle and inspection commands

Beyond scaffolding, the framework exposes commands to inspect a running application — saving the "why doesn't it work" half-hour:

  • bin/console bridge:doctor — readiness checks (FrankenPHP binary present, Mercure key configured, QML path writable, env vars set, dev backend reachable).
  • bin/console bridge:resources — list all registered bridge resources with their topics and routes.
  • bin/console bridge:topics — list active Mercure topics with current subscriber counts.
  • bin/console bridge:publish <topic> <json> — manually publish a test envelope to smoke-test wiring without needing UI.
  • Standard Symfony commands continue to work: make:migration, doctrine:migrations:migrate, cache:clear, debug:router, etc.

Customisation

Maker-bundle templates are overridable via skeleton paths. We document the override locations so application authors can tailor generated code style (preferred property visibility, custom base classes, naming conventions) without forking the bundle.

One-command dev loop

A Makefile at the skeleton root provides:

  • make dev — starts frankenphp run --watch against the Symfony source and launches the Qt host in dev mode in parallel; tears both down on Ctrl-C.
  • make doctor — passthrough to bridge:doctor.
  • make quality — runs the same checks the CI quality job runs.

Plain Make rather than Task/Just keeps the toolchain lean — no extra install, available on every dev box and CI runner. Scaffolding still goes through bin/console make:bridge:* directly.

Intent: clone the skeleton, run make dev, code within 30 seconds.

Editor support and debugging

Documented in the skeleton's README so first-day setup doesn't require guesswork.

  • Recommended editors: VS Code with the Qt extension + qmlls for QML, plus Intelephense or Phpactor for PHP. JetBrains users get PhpStorm + Qt Creator side-by-side; both can attach to the running pair.
  • PHP debugger: Xdebug into FrankenPHP works out of the box — make dev-debug starts FrankenPHP with XDEBUG_MODE=debug,develop and XDEBUG_TRIGGER=1, listening on the Symfony container's port. Skeleton ships a .vscode/launch.json and a .idea/runConfigurations/ example wired up.
  • QML debugger: Qt's QML debugger attaches via -qmljsdebugger=port:N. The dev-mode host accepts a --qml-debug-port=N flag that opens it; off by default.
  • Logs: in dev mode, child stdout/stderr is forwarded to the host's stderr so everything streams to one terminal. Bundled mode writes to var/log/ and the host surfaces a "Open log folder" menu item.

Roadmap impact

symfony/maker-bundle is a Phase-1 dependency. The first scaffolded application in Phase 3 (examples/todo) is itself produced by running the makers — this is how we validate that generator output is genuinely usable rather than a glossy demo.

9. Project Layout

php-qml/
  PLAN.md
  README.md
  framework/
    php/                        # the Symfony bundle (composer package)
      src/
      config/
      composer.json
    qml/                        # the Qt module
      src/                      # C++ plugin (BackendConnection, MercureClient, models)
      qml/                      # pure-QML helpers (AppShell, RestClient)
      CMakeLists.txt
    skeleton/                   # `composer create-project php-qml/app` template
      symfony/                  # minimal Symfony app pre-wired with the bundle
      qml/                      # minimal QML app pre-wired with the module
      CMakeLists.txt
      Caddyfile.tmpl
  examples/
    todo/                       # POC application (see §10)
  packaging/
    linux/                      # AppImage recipe
    macos/                      # .app + codesign script
    windows/                    # NSIS / MSIX recipe
  .gitea/
    workflows/
      ci.yml                    # quality + build on every push/PR
      release.yml               # signed artefacts on v* tags
  docs/

The framework ships as two artefacts plus a skeleton:

  • A Composer package (the Symfony bundle).
  • A Qt module (header-only QML + a compiled plugin).
  • A skeleton repo that wires them together.

10. POC Scope — examples/todo

Smallest application that exercises every framework capability.

Features:

  1. Single window with a list of todos and an "add" input.
  2. Add / toggle-done / delete commands hit /api/todos.
  3. List updates via app://model/todos push.
  4. Multi-window test: "Open another window" menu item — opens a second top-level window. Editing in one window updates the other within ~50 ms. This proves the reactive model is actually reactive and not just a glorified fetch.
  5. Persistence via Doctrine + SQLite in the user's app data dir.
  6. Survives backend crash: kill the child, host restarts it, Mercure re-subscribes, models recover.

What this proves:

  • Process lifecycle works.
  • Transport works on the target OS.
  • The reactive list model handles upsert/delete correctly.
  • SSE reconnect with Last-Event-ID actually resumes without dupes/gaps.
  • Packaging produces something a non-developer can double-click.

11. Build, Packaging, and CI

Build inputs

  • Qt host: CMake, Qt 6.x (LTS), qt_add_qml_module for the framework's QML module.
  • PHP runtime: download the official static frankenphp binary per platform at build time, with a pinned URL and verified sha256, embedded next to the host executable.
  • Symfony app: composer install --no-dev into a dist/symfony/ directory; shipped as a directory tree (not a phar — keeps debugging sane and lets the runtime lazy-load).

Per-OS packaging

  • Linux: AppImage. linuxdeployqt for Qt, FrankenPHP and the Symfony tree dropped into the AppDir.
  • macOS: standard .app bundle, FrankenPHP at Contents/MacOS/frankenphp, Symfony at Contents/Resources/symfony/. Codesign + notarize.
  • Windows: NSIS or MSIX installer, windeployqt for the Qt deps.

First-run migrations

Host invokes bin/console doctrine:migrations:migrate --no-interaction against the user's data dir before opening the SSE connection.

Auto-update

Shipping a desktop binary means planning for updates from day one — there is no composer update for an end user.

  • Release feed: the framework looks for a JSON appcast at a configurable URL (default: a static latest.json attached to the most recent Gitea Release) describing the current version, per-platform installer URLs, sha256, and signed release notes.
  • Per-platform mechanism:
    • Linux: AppImageUpdate (delta updates, signed by the release GPG key).
    • macOS: Sparkle 2 (EdDSA-signed feed; integrates with notarized .app bundles).
    • Windows: WinSparkle (DSA/Ed25519-signed feed; works with both NSIS installers and standalone).
  • UX: check on launch and once per N hours; offer install on next restart, never auto-restart. User can opt out via settings; opt-out is respected even on critical updates (we show a banner, we don't force).
  • Release artefacts: the CI release job (§Continuous integration below) emits both the installer and the appcast entry, so updating the feed is part of cutting a release, not a separate manual step.

Distribution UX

The first-launch experience on each OS hides several non-obvious foot-guns. They're cheap to address if planned for, expensive to retrofit.

  • macOS Gatekeeper: notarization is mandatory; without it, users get "app is damaged" instead of a meaningful prompt. Notarization requires an Apple Developer account ($99/year — operational cost).
  • Windows SmartScreen: Authenticode signing reduces but does not eliminate the unknown-publisher prompt; reputation is built over time. EV (Extended Validation) certs bypass it but cost more.
  • Antivirus heuristics: Qt + a Go binary (FrankenPHP) + an embedded PHP runtime + a SQLite file is exactly the layered structure that trips heuristic AV scanners on Windows. We pre-emptively submit signed builds to Microsoft Defender and major vendors after each release.
  • Linux file-association / desktop-entry: AppImage doesn't auto-register file types or myapp:// URL schemes. Document the per-distro install steps, or recommend AppImageLauncher.
  • App Store paths are separately scoped. The Mac App Store sandbox forbids spawning arbitrary subprocesses, which is incompatible with our bundled-FrankenPHP architecture; MAS distribution is out of scope. The Microsoft Store accepts MSIX with full-trust, which is feasible but requires its own packaging variant; defer past v1.

Performance budgets

Numbers we hold the line on; regressions block a release.

  • Cold start (bundled mode), commodity hardware: ≤ 2 s from process launch to first window paint with backend ready.
  • Warm start: ≤ 1 s.
  • Idle memory (single window, no data): ≤ 200 MB resident.
  • Bundle size on disk: target ≤ 120 MB per platform installer; hard cap 200 MB.
  • First-fetch list render (1 000 rows): ≤ 250 ms from request to fully painted ListView.

CI captures these via a "performance smoke" job that runs the POC todo app's automated launch and lists; trends are tracked across builds. Detail-level tests (perf benchmarks per component) are out of scope for v0 but the harness is in place from Phase 4.

Continuous integration (Gitea Actions)

The build pipeline runs in Gitea Actions. Workflow YAML is largely portable from GitHub Actions (Gitea's act_runner consumes the same syntax), so we can reuse community actions like shivammathur/setup-php and jurplel/install-qt-action directly. We pin every action by SHA, never @main.

Trigger model

  • PRs and pushes to main — quality checks plus a full build matrix; artifacts uploaded for inspection, nothing published.
  • Tag pushes matching v* — same as above, plus signing and a Gitea Release attaching the per-platform installers and a GPG-signed SHA256SUMS.

Jobs

  1. quality — single Linux job, runs on every push: PHPStan, php-cs-fixer (check mode), qmllint, PHPUnit, QML tests. Cancel-fast on first failure.
  2. build — fan-out matrix per target platform:
    • Linux runner → AppImage
    • macOS runner (self-hosted; see Runners) → signed .app + .dmg
    • Windows runner → NSIS installer (signed when release secrets are present)
  3. release — only on v* tags. Aggregates artifacts from build, generates SHA256SUMS, signs it with GPG, creates the Gitea Release via the Gitea release action, and uploads everything.

Per-build steps (shared shape across platforms)

Checkout → restore caches → install Qt SDK via aqtinstall → install PHP toolchain → fetch and verify the pinned FrankenPHP static binary → composer install --no-dev for the embedded Symfony app → CMake configure + build → run the platform-native deploy tool (linuxdeployqt / macdeployqt / windeployqt) → run the installer builder (appimagetool / create-dmg / makensis) → code-sign if release secrets are present → upload the artifact. Each per-OS workflow is a thin wrapper around this shared shape, so behaviour stays consistent across platforms.

Runners

  • Linux and Windows: commodity self-hosted runners (Linux containerised, Windows VM). Fine on modest hardware.
  • macOS: bare-metal or Apple-licensed VM under the org's control — typically a self-hosted Mac mini. This is a hard prerequisite for shipping the macOS build; document it in the project README.

Secrets

Held in Gitea repo secrets, scoped to the release job only — PRs from forks see none of these and skip signing automatically.

  • MACOS_CERTIFICATE, MACOS_CERT_PASSWORD, MACOS_NOTARY_KEY — codesign + notarize.
  • WINDOWS_PFX, WINDOWS_PFX_PASSWORD — Authenticode signing.
  • GPG_KEY, GPG_PASSPHRASESHA256SUMS.asc signing.

Caching

  • Qt SDK is the dominant cost (~3 GB). Cache key: qt-${qt_version}-${os}.
  • Composer cache keyed on composer.lock hash.
  • CMake build directory cache keyed on os + qt_version + cmake_config_hash for non-clean builds; opt-in per workflow.

Reproducibility

Every dependency is version-pinned: Qt, the FrankenPHP binary URL with verified sha256, the PHP version, every action reference. The Caddyfile and Symfony build are baked at build time; the produced binary fetches nothing at runtime.

Workflow files

  • .gitea/workflows/ci.ymlquality + build on every push and PR.

  • .gitea/workflows/release.ymlv* tag triggered, depends on build, signs and uploads.

12. Open Questions and Risks

Topic Question / Risk Current bias
Cold start Symfony kernel boot on first launch will be visible. Acceptable for v0; show splash. Investigate app.cache warmup at build time.
Bundle size Qt + FrankenPHP + Symfony is heavy (likely 60120 MB). Budgeted (§11, Performance budgets). Document and accept.
QML SSE client No first-party SSE in Qt. We implement it. Estimated ~200 LOC, well-trodden protocol. Low risk but real work.
Native dialogs (file pickers, notifications) Where do they live? QML side, via Qt — PHP shouldn't know. Document the boundary.
Auth model Per-session bearer is enough? Yes for local-only. If we ever expose the Mercure hub on LAN, revisit.
FrankenPHP-as-library CGo-embed FrankenPHP into the Qt host as a single process? Out of scope for POC. Note as a future optimisation; subprocess is fine and debuggable.
Migrations on schema change Auto-run on launch is convenient but risky. Auto-run for v0; add a "backup before migrate" step before v1.
Qt LGPL relinkability Shipping Qt under LGPL legally requires letting users relink against their own Qt build. Document the relink procedure for v0; revisit a commercial Qt licence if/when distribution scales.
Linux distribution channels AppImage only, or also Flathub / Snap? AppImage for POC (simplest). Flathub a v1 stretch goal — better discoverability, but adds packaging burden.
Multi-arch macOS arm64+x86_64 universal? Linux/Windows ARM? macOS universal from day one (Apple Silicon dominant). Linux ARM and Windows ARM deferred until user demand surfaces.
Testing strategy What test layers exist beyond PHPUnit and qmllint? Add qmltestrunner for QML units plus a thin bridge-integration suite that boots host + child and exercises the IPC stack. End-to-end UI testing deferred.
Maker output drift Generated code rots silently as the framework evolves. The POC todo app (built via the makers) doubles as a snapshot test; CI compares maker output to a checked-in reference.
i18n Symfony Translator (XLIFF) and Qt Translator (.ts) are separate pipelines. Keep them separate. Document the boundary (PHP strings in XLIFF, QML strings in .ts) and provide a single locale switch that fans out.
Data backup / export SQLite in user data dir is convenient until the user changes machines or recovers from a botched migration. Ship a bridge:export console command and a UI hook for backup-to-file from v1. Pre-migration auto-backup as in the migrations row.
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

Phased, each phase ends with something runnable.

Phase 0 — Spike (throwaway)

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.

Concrete spec

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.

Layout:

spike/
  README.md           # how to run, what it proves, expected output
  run.sh              # builds (if needed) and runs FrankenPHP + the Qt host
  Caddyfile           # binds 127.0.0.1:8080, enables Mercure, routes index.php
  .env.local          # MERCURE_PUBLISHER_JWT_KEY (dev-only static key)
  .gitignore          # bin/, build/
  bin/frankenphp      # downloaded static binary, gitignored
  php/
    index.php         # GET /api/ping → returns pong, publishes to Mercure
  qt/
    CMakeLists.txt    # minimal Qt 6 + QML project
    main.cpp          # QGuiApplication + QQmlApplicationEngine + spawns frankenphp child
    Main.qml          # window: status indicator, Ping button, event log
    Mercure.qml       # tiny SSE client (text/event-stream parser via QNetworkReply)

Flow:

  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.

Hardcoded for the spike:

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

Done criteria:

  • Click "Ping" → response text updates and an event line appears in the log within ~50 ms.
  • Killing bin/frankenphp externally → Qt host visibly shows the connection dropping.
  • Re-running ./run.sh → everything reconnects.
  • A brief writeup in spike/README.md of what the spike proved and any surprises.

Out of scope (lands in Phase 1+): optimistic updates, Last-Event-ID resume, per-session secret, single-instance lock, packaging, Symfony.

Phase 1 — Framework skeleton (dev mode from day one)

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

Detailed scope

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.

Naming and identifiers (working, settable before any code):

Thing Value
Composer package php-qml/bridge
PHP namespace PhpQml\Bridge\
Qt module URI PhpQml.Bridge
C++ namespace PhpQml::Bridge
Symfony minimum ^8.0
PHP minimum ^8.3
Qt minimum 6.5 LTS (build), 6.11 is what's on the dev box

Directory layout (additions over Phase 0):

framework/
  php/                 # Composer: php-qml/bridge
    src/
      BridgeBundle.php
      Bridge/{Publisher,SessionAuthenticator}.php
      Controller/HealthController.php
      Command/BridgeDoctorCommand.php
    config/services.yaml
    composer.json
    phpunit.xml.dist
    tests/
  qml/                 # Qt module PhpQml.Bridge
    src/{BackendConnection,SingleInstance,MercureClient}.{h,cpp}
    qml/{AppShell.qml,RestClient.qml}
    CMakeLists.txt
  skeleton/
    symfony/           # Symfony app pre-wired with the bundle
      composer.json
      bin/console
      config/{packages,routes,bundles.php}
      public/index.php
      src/Kernel.php
      .env, .env.local
    qml/               # QML app pre-wired with the module
      CMakeLists.txt
      main.cpp
      Main.qml
    Caddyfile          # FrankenPHP config for dev mode
    Makefile           # make dev / make doctor / make quality
.gitea/
  workflows/
    ci.yml             # quality job

Sub-commits (each ends with something runnable):

  1. Repo restructure — empty framework/php, framework/qml, framework/skeleton, .gitea/workflows/ci.yml stub. Update root .gitignore. Spike still in place.
  2. Symfony bundleBridgeBundle, Publisher, HealthController, SessionAuthenticator with PHPUnit smoke tests.
  3. bridge:doctor command — readiness checks (env vars, Caddyfile present, FrankenPHP reachable in dev mode, Mercure JWT non-empty).
  4. Qt foundation typesBackendConnection (dev mode: reads BRIDGE_URL, BRIDGE_TOKEN from env or CLI flag), SingleInstance (QLocalServer lock + arg forwarding). Buildable but not visibly useful yet.
  5. Qt transport typesMercureClient (C++ SSE: text/event-stream parse, exponential backoff, Last-Event-ID resume), RestClient.qml (idempotency-key auto-attach, problem+json error mapping).
  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 spikespike/ deleted; key lessons already captured in PLAN.md and the framework code.

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.

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.
  • make:bridge:resource maker implemented end-to-end (entity + controller + lifecycle wiring + QML snippet).
  • 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.

Stack additions (skeleton):

Thing Choice
ORM Doctrine ORM 3.x + DoctrineBundle + DoctrineMigrationsBundle
Dev DB SQLite at var/data.sqlite (zero-config)
Default ID type UUIDv7 via symfony/uid (the maker takes --int-id for an auto-increment integer if asked)
Pagination cursor-based (opaque base64-JSON of {lastId, lastSortKey}), default page size 50
Doctrine→Mercure trigger postPersist / postUpdate / postRemove event subscribers (synchronous)

Sub-commits (each ends runnable):

  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 ReconnectingOnline 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-codes 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.

Phase 4 — Bundled mode, packaging, release CI, and auto-update

  • 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)
4b macOS .app + .dmg + Sparkle 2 + notarization self-hosted macOS runner, Apple Developer cert ($99/yr)
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),
    • ensures var/data.sqlite, var/cache/, var/log/ exist there,
    • 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):

  • macOS .app bundle, codesign, notarization, Sparkle 2 integration.
  • Windows NSIS, Authenticode, WinSparkle integration.
  • Multi-arch (Linux ARM64 / Windows ARM) — wait for user demand.
  • make:bridge:event, make:bridge:read-model — Phase 3.x.
  • 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.

Phase 5 — DX polish

  • Project skeleton via Composer / a small CLI to scaffold a new app.
  • Logging: child stdout/stderr surfaced into Qt's log, optional developer console window.
  • Hot-reload story documented end-to-end (PHP via FrankenPHP --watch, QML via Qt tooling).

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.