Reproduces with the v0.1.1 AppImage on the second launch (same user
data dir, fresh AppImage mount):
phpqml.bridge.bundled: symfony: "/tmp/.mount_Todo-xllnOHH/..."
Cannot load migrations from "/tmp/.mount_Todo-xDBkOfG/.../migrations"
^^^^^^^
stale path from PREVIOUS launch's cache
Symfony compiles `kernel.project_dir` (an absolute path) into its
cached container under var/cache/. We redirect var/cache into the
user data dir for read-only-mount survival (v0.1.0 fix), but the
*content* of that cache references the mount path that was active
when the cache was built. Next launch gets a different
/tmp/.mount_<random>; the cached refs are stale; first
project_dir-sensitive lookup blows up (doctrine migrations was the
canary; would also surface as misrouted assets, broken Twig template
paths, etc.).
Fix: BackendConnection::initBundledMode does
QDir(cacheDir).removeRecursively() right after creating the dirs but
before runMigrations spawns the doctrine subprocess. Symfony rebuilds
the cache against the current mount on every launch. Cost: ~1-2s of
warmup per cold start.
Permanent fix is build-time cache warmup (ship the prod cache inside
the AppImage, copy to user data dir on first launch, no per-launch
warmup) — already tracked as a v0.2.0 item in PLAN.md §13. v0.1.1
takes the simpler always-wipe approach since it's bugfix-class.
Regression guard: examples/todo/tests/bundled-supervisor.sh gains a
"2nd launch from fresh staging" step that tears down the first host,
re-stages a fresh fake AppImage layout (different /tmp dir = different
"mount path" from BackendConnection's perspective), and asserts
/healthz comes back up. Without the cache wipe, that step would fail
exactly the way doctrine did in the user's report.
Verified locally:
- bundled-supervisor.sh passes (incl. 2nd-launch step)
- Real AppImage: two consecutive launches both reach
"phpqml.bridge.bundled: migrations OK" + frankenphp spawn
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
examples/todo
The framework's POC application: a real Qt/QML todo app whose backend is a Symfony service generated entirely by the make:bridge:* makers. Demonstrates every architectural primitive in PLAN.md §13 Phase 3.
What's here that wasn't in the skeleton
Todoresource (entity + controller + QML snippet) generated viamake:bridge:resource Todo.MarkAllDonecommand generated viamake:bridge:command MarkAllDone, with a body that flipsdone = trueon every todo.TodoWindow.qmlsecond-window scaffold generated viamake:bridge:window Todo, customised to embed a read-only mirror of the sameReactiveListModel.Main.qmlrewritten as a real list UI with add input, per-row toggle/delete, "mark all done", and "open second window".
Everything cross-side (PHP ↔ QML) is maker-generated. There is no handwritten bridge glue in this example.
Run it
make install # composer install
make build # cmake + qt host
make doctor # bridge:doctor — readiness check
make dev # FrankenPHP --watch + Qt host
Add a few todos, toggle them, click "Mark all done", click "Open second window" — both windows stay in sync.
Multi-window test
make devstarts the app.- Add three todos via the input field.
- Click Open second window. A
Todos (mirror)window opens. - In the main window, toggle one of the todos. The mirror flips its row within ~50 ms (Mercure SSE).
- Add a new todo in the main window. It appears in the mirror within ~50 ms.
- Click Mark all done in the main window. Both windows show all rows ticked simultaneously.
Each window has its own ReactiveListModel instance subscribed to the same app://model/todo topic; the framework keeps them coherent without per-window glue.
Crash-and-recover test
make devis running.- Add a todo so the list is non-empty.
- From another terminal:
pkill -f 'examples/todo/symfony.*frankenphp'. - The app's
AppShellshows the Reconnecting banner (visible on the second window; the main window keeps its own status display). - Restart the FrankenPHP child: from the example dir,
cd symfony && frankenphp run --watch --config ../Caddyfile— or just re-runmake dev. - The app flips back to Online and re-fetches the list. No row corruption.
Layout
todo/
Caddyfile # FrankenPHP / Mercure config (port 8765, anonymous SSE)
Makefile # build / dev / doctor / quality
scripts/dev.sh
symfony/
composer.json # path repo points one level deeper at framework/php
config/
src/Entity/Todo.php # generated by make:bridge:resource
src/Controller/TodoController.php # generated, CRUD on /api/todos
src/Controller/MarkAllDoneController.php # generated, body filled in
public/index.php
bin/console
.env
migrations/
qml/
CMakeLists.txt
main.cpp
Main.qml # real todo UI (add / toggle / delete / mark-all / 2nd-window)
TodoList.qml # generated; also reused by the second window
TodoWindow.qml # generated, customised to embed mirror list
What this example proves
- The headline workflow scales: a non-trivial app's PHP/QML wiring is all generated.
ReactiveListModelhandles in-flight optimistic mutations (pendingrole drops opacity on toggled rows until the Mercure echo lands).- Two windows of the same QML app stay coherent under mutations from either side.
- Stopping FrankenPHP mid-session triggers
Reconnecting→OfflineUI; restart restoresOnlineand re-fetches without dupes. Idempotency-Keyround-trips through to Mercure ascorrelationKey— visible indev.logif yougrep correlationKey.
Out of scope here
- A CI-driven version of the multi-window / crash-recover tests lives in Phase 3 sub-commit 4 as a bridge-integration suite.
- No persistence options (export, backup) — same SQLite
var/data.sqliteas the skeleton. Apps move to Postgres by overridingDATABASE_URL.
AppImage packaging (Phase 4a)
FRANKENPHP=/path/to/frankenphp make appimage
./build/Todo-x86_64.AppImage
make appimage stages a composer install --no-dev copy of the Symfony app, downloads linuxdeploy + appimagetool + the AppImageUpdate sidecar (cached under packaging/linux/tools/, gitignored), and produces build/Todo-x86_64.AppImage (~150 MB). The AppImage carries everything needed: Qt runtime, the bundled FrankenPHP binary, the Symfony app, and an AppImageUpdate.AppImage sidecar for in-place updates.
When the AppImage runs without BRIDGE_URL set, BackendConnection switches to bundled mode: it spawns the embedded FrankenPHP, generates a per-session bearer token, runs first-launch migrations into ~/.local/share/php-qml/todo/var/data.sqlite, and supervises the child. Killing FrankenPHP from outside the AppImage triggers an automatic restart with a fresh token (PLAN.md §3 Edge cases — Per-session secret rotation).
Auto-update
The AppImage is built with an embedded update-info ELF section pointing at the canonical Gitea Releases URL for its tag (set via APPIMAGE_UPDATE_INFO at build time). The bundled sidecar implements the actual download and patch via zsync.
From QML:
Connections {
target: BackendConnection
function onUpdatesAvailable() { ... }
function onNoUpdatesAvailable() { ... }
function onUpdateApplied() { ... } // restart prompt
}
Button { text: "Check for updates"; onClicked: BackendConnection.checkForUpdates() }
Button { text: "Update"; onClicked: BackendConnection.applyUpdate() }
Performance smoke
make perf
Asserts the §11 Performance budgets: bundle ≤ 200 MB, cold start ≤ 2 s (4 s on shared CI runners), idle RSS ≤ 200 MB. CI runs this on every release tag before publishing.