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>
php-qml
A framework for native desktop applications with a Symfony / FrankenPHP backend and a Qt / QML frontend, packaged as a single distributable per OS.
Status: Phase 5 / pre-v0.1.0. Phases 0–4a are merged (working framework, real POC, Linux AppImage, auto-update, release CI). macOS and Windows packaging are deferred to 4b/4c. See CHANGELOG.md.
What it is
php-qml lets a PHP developer write a desktop app using ordinary Symfony on the backend and ordinary QML on the frontend. The two halves run as a process pair inside one bundled binary:
- A Qt/QML host owns the window, input, and rendering.
- A bundled FrankenPHP child runs a Symfony app in worker mode.
- They communicate over a local socket — HTTP for commands and queries, Mercure SSE for state push.
It is not a PHP↔Qt language binding — the languages run in separate processes; the bridge is a wire protocol, not an FFI layer. That deliberately avoids the failure mode that left php-gtk and php-qt unmaintained.
60-second tour
git clone https://src.bundespruefstelle.ch/magdev/php-qml && cd php-qml
# Scaffold a fresh app
./bin/php-qml-init my-app
# Run it
cd my-app
make doctor # readiness check
make dev # FrankenPHP --watch + Qt host
Add a reactive resource (entity + REST controller + QML snippet) with one maker:
cd my-app/symfony
bin/console make:bridge:resource Todo
bin/console make:migration && bin/console doctrine:migrations:migrate -n
make dev opens the Qt window, connection state flips to Online, and the generated TodoList.qml shows a list whose ReactiveListModel is auto-subscribed to app://model/todo over Mercure. There is no handwritten cross-side glue.
For a non-trivial app with a multi-window test, crash-recovery test, and AppImage packaging, see examples/todo/.
Documentation
The full developer documentation lives under docs/:
- Getting started — prerequisites by distro, first project, troubleshooting.
- Architecture — process pair, transport, dev vs bundled mode.
- Update semantics — connection state machine, optimistic mutations, idempotency.
- Reactive models —
ReactiveListModel,ReactiveObject, Mercure dual-publish. - Makers —
make:bridge:resource/command/window. - Dev workflow — hot reload, dev console, editor setup,
bridge:doctor. - Bundled mode — supervisor, per-session secret rotation, first-launch migrations.
- Linux packaging —
make appimage, auto-update, performance budgets. - Configuration reference — env vars, CLI flags.
- QML API reference / PHP API reference — singletons, components, attributes, services.
Design rationale and roadmap live in PLAN.md. User-facing changes per release are in CHANGELOG.md.
Tech stack
PHP 8.4+ · Symfony 8 · Doctrine ORM 3 · FrankenPHP 1.12+ (worker mode) · Mercure · Qt 6.5+ · CMake · Composer · Gitea Actions
Roadmap
- Phase 0 ✅ throwaway transport spike.
- Phase 1 ✅ framework skeleton, dev mode, single-instance lock, CI quality gate.
- Phase 2 ✅ reactive models, update semantics, headline maker.
- Phase 3 ✅ POC todo app, integration + snapshot tests.
- Phase 4a ✅ bundled mode, Linux AppImage, release CI, AppImageUpdate.
- Phase 4b/4c ⏳ macOS / Windows packaging.
- Phase 5 🚧 DX polish — dev console, init script, hot-reload docs, v0.1.0 prep.
Contributing
Active development happens on the dev branch; main only carries release commits. Pull requests target dev.
cd framework/php && composer quality # PHPStan + cs-fixer + PHPUnit
cd examples/todo && make quality # adds qmllint + integration test
A dedicated CONTRIBUTING.md arrives with Phase 5's wrap-up.
Versioning
Semantic Versioning — MAJOR.MINOR.BUGFIX. Pre-v1.0.0, minor bumps may break public API.
License
To be decided before v0.1.0 is tagged. The framework's own code will be permissively licensed; Qt is shipped under LGPL with relinkability obligations — see PLAN.md §12.