The README still framed the project as "Phase 5 / pre-v0.1.0" and the docs predated the v0.2.0 surface (typed BridgeOp, public service interfaces, port negotiation, pre-migration auto-backup, bridge:export, periodic auto-update, two new makers, qmltestrunner). Bring them in line with what's actually shipped, and add badges (release, license, PHP, Symfony, Qt, FrankenPHP, CI, platform) to the README so the status is legible at a glance. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
11 KiB
Getting started
End-to-end walkthrough: install prerequisites, scaffold a project, run it, add a reactive resource, package it. Plan on ~15 minutes if FrankenPHP and Qt are already installed; ~45 minutes from a clean machine.
If something doesn't work the way this page says, the Troubleshooting section at the bottom covers the cases we've actually hit.
1. Prerequisites
| Tool | Minimum | Notes |
|---|---|---|
| PHP | 8.4 | Symfony 8 enforces this. Earlier PHP will fail composer install. |
| Composer | 2.x | |
| FrankenPHP | 1.12.2 | Single static binary. Either on PATH or pointed at via FRANKENPHP. |
| Qt | 6.5 | LTS. We test on 6.5–6.11. |
| CMake | 3.21+ | |
| GCC/Clang | C++20 | g++ ≥ 11 or clang++ ≥ 14. |
| make | any | Plain GNU make is fine. |
| git, curl, jq, rsync | any | Used by the dev / packaging scripts. |
Install by distribution
openSUSE Tumbleweed
sudo zypper install -t pattern devel_basis devel_C_C++
sudo zypper install \
qt6-base-devel qt6-declarative-devel \
qt6-quickcontrols2-devel qt6-tools-devel qt6-quickcontrols2-imports \
cmake gcc-c++ git rsync curl jq \
php8 php8-cli php8-pdo php8-sqlite php8-mbstring php8-zip \
composer
Fedora 40+
sudo dnf install \
qt6-qtbase-devel qt6-qtdeclarative-devel \
qt6-qtquickcontrols2 qt6-qttools-devel \
cmake gcc-c++ git rsync curl jq \
php-cli php-pdo php-sqlite php-mbstring php-zip \
composer
Debian / Ubuntu (24.04+)
PHP 8.4 isn't in vanilla Ubuntu yet — use Ondřej Surý's PPA.
sudo add-apt-repository ppa:ondrej/php
sudo apt update
sudo apt install \
qt6-base-dev qt6-declarative-dev qt6-quickcontrols2-dev qt6-tools-dev \
libqt6opengl6-dev libqt6quick6 libqt6quickcontrols2-6 \
cmake g++ git rsync curl jq \
php8.4-cli php8.4-pdo php8.4-sqlite3 php8.4-mbstring php8.4-zip php8.4-curl \
composer
Arch Linux
sudo pacman -S \
qt6-base qt6-declarative qt6-quickcontrols2 qt6-tools \
cmake gcc git rsync curl jq \
php php-sqlite \
composer
FrankenPHP
Grab the static linux/amd64 binary from https://github.com/php/frankenphp/releases:
curl -fsSL -o /tmp/frankenphp \
https://github.com/php/frankenphp/releases/download/v1.12.2/frankenphp-linux-x86_64
sudo install -m 0755 /tmp/frankenphp /usr/local/bin/frankenphp
frankenphp version # expect: FrankenPHP v1.12.2 …
Or build it from source if you need a custom PHP/extensions set; the FrankenPHP project has good build instructions.
Verify
php --version # 8.4.x
composer --version
frankenphp version
qmake6 --version # Qt 6.5.x or newer
cmake --version
If qmake6 isn't on PATH, the dev packages are installed but update-alternatives (Debian) hasn't symlinked it. Use qmake-qt6 or /usr/lib/qt6/bin/qmake directly when configuring CMake.
2. Get the framework
git clone https://src.bundespruefstelle.ch/magdev/php-qml
cd php-qml
Optional: run the framework's own quality gate to confirm your PHP toolchain is wired up correctly:
cd framework/php && composer install && composer quality
cd ../..
3. Scaffold a project
bin/php-qml-init is a single bash script that copies the skeleton into a new directory and rewrites every identifier (CMake project name, Qt target, QML module URI, app title, single-instance lock id, composer path-repo, CMake add_subdirectory).
./bin/php-qml-init my-app
Output you can expect:
→ copying skeleton → /…/my-app
→ rewriting identifiers (snake=my_app, pascal=MyApp)
→ path-repo → /…/php-qml/framework/php
→ qml framework path → /…/php-qml/framework/qml
→ composer install
…
→ first-run migrations
…
✓ Scaffolded 'my-app' at /…/my-app
By default the new project references this framework checkout via absolute paths — edits to framework/php or framework/qml are picked up live. Pass --vendor to copy them into my-app/.bridge/ and my-app/.bridge-qml/ so the project is portable away from the checkout. Pass --git to git init and create an initial commit.
See the php-qml-init reference for the full flag list.
4. First run
cd my-app
make doctor # bridge:doctor — readiness check
make dev # FrankenPHP --watch + Qt host
make doctor should print a green checklist (Doctrine connection, Mercure URL, bridge token, JWT secret, etc.).
make dev opens the Qt window and runs FrankenPHP in worker mode in another process group. Watch for:
- The status dot in the toolbar flips to green within ~1 s — connection state is
Online. - The Mercure dot turns green — SSE subscription is live.
- Click Ping — round-trip via
/api/pingreturns; the next event from Mercure shows up in the log pane within ~50 ms.
Ctrl-C in the terminal stops both processes cleanly (scripts/dev.sh traps SIGTERM and tears down the process group).
5. Add a reactive resource
The headline workflow — entity + REST controller + QML snippet — is one maker invocation:
cd symfony
bin/console make:bridge:resource Todo
# created: src/Entity/Todo.php # #[BridgeResource] + UUIDv7 id
# created: src/Controller/TodoController.php
# created: ../qml/TodoList.qml
bin/console make:migration
bin/console doctrine:migrations:migrate -n
The maker's defaults:
- UUIDv7 ID. Use
--int-idif you prefer auto-incrementing integers. #[BridgeResource]attribute on the entity. The bundle's Doctrine subscriber sees this and auto-publishespostPersist/postUpdate/postRemoveto two Mercure topics:app://model/todo— collection topic, watched byReactiveListModel.app://model/todo/<id>— entity topic, watched byReactiveObject.
/api/<name>CRUD controller —GET /api/todos,POST /api/todos,GET/PATCH/DELETE /api/todos/<id>.<Name>List.qml— a starterListViewbound to aReactiveListModeldoing the initial GET and subscribing to the collection topic.
Use the generated QML from your Main.qml:
import Todo // local module — exposes TodoList.qml
TodoList { anchors.fill: parent }
Run make dev again and post a todo from another terminal:
curl -X POST http://127.0.0.1:8765/api/todos \
-H 'Authorization: Bearer devtoken' \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: my-key-1' \
-d '{"title":"buy milk","done":false}'
It appears in the Qt window within ~50 ms via Mercure SSE. The Idempotency-Key round-trips back as correlationKey so any in-flight optimistic mutation can match the echo (see Update semantics).
6. Package as an AppImage (optional)
FRANKENPHP=/usr/local/bin/frankenphp make appimage
./build/MyApp-x86_64.AppImage
make appimage produces a single ~150 MB file containing Qt, the Symfony app, the FrankenPHP binary, and an AppImageUpdate sidecar. When the AppImage runs without BRIDGE_URL set, BackendConnection switches to bundled mode — see Bundled mode and Linux packaging.
7. What to read next
- Architecture — what's actually happening when
make devruns. - Reactive models — how
ReactiveListModeldecides to upsert / delete / re-fetch. - Update semantics — when optimistic mutations roll back; what
pendingmeans. - Dev workflow — hot-reload, dev console (`Ctrl+``), editor configs.
- Makers — the rest of the maker family (
command,window).
Troubleshooting
make dev exits with "frankenphp: command not found"
scripts/dev.sh looks for frankenphp on PATH or via FRANKENPHP=/path/to/frankenphp. Fix one of those:
which frankenphp
# or
FRANKENPHP=/path/to/frankenphp make dev
Qt window opens but the status dot stays red / "Reconnecting"
FrankenPHP didn't bind. From another terminal:
curl -i http://127.0.0.1:8765/healthz
Connection refused— FrankenPHP didn't start. Checktail -f symfony/var/log/dev.logand the terminalmake devis running in.401on/api/*— the host is sending the wrong bearer. In dev that'sBRIDGE_TOKENfrom.env; the Qt host reads it viaBackendConnection.tokenwhich defaults toqgetenv("BRIDGE_TOKEN").- Port 8765 already taken — another
make devis still running.pkill -f frankenphpand retry. (Bundled-mode AppImages don't share this failure mode — they negotiate a free ephemeral port at launch; see Bundled mode §port negotiation.)
composer install fails with "your php version (8.3.x) does not satisfy"
Symfony 8 is PHP 8.4+. Either install PHP 8.4 (see distro instructions above) or downgrade Symfony in composer.json — but then several framework features (typed iterables, etc.) won't work.
make build fails with "Could not find a package configuration file provided by 'Qt6'"
Qt 6 dev packages aren't installed, or CMake can't find them. Try:
CMAKE_PREFIX_PATH=/usr/lib/qt6 make build
On Debian/Ubuntu the path is typically /usr/lib/x86_64-linux-gnu/cmake/Qt6.
make:bridge:resource exits with "no maker bundle"
composer install finished without dev dependencies (--no-dev). Re-install with dev deps:
cd symfony && composer install
The maker bundle is require-dev, so production AppImage builds (which use --no-dev) intentionally don't include it.
bridge:doctor --connect reports "tokenRotated received"
Cosmetic in dev mode; the bundled-mode supervisor uses that signal when restarting the FrankenPHP child. In dev mode BRIDGE_TOKEN is fixed and the signal never fires.
Two windows of the same app open instead of one
The single-instance lock socket is per-SingleInstance(name) value (which php-qml-init derives from the app name). If you renamed the binary between runs, stale ~/.local/share/<name>/<name>.sock lives on. Either remove it or just close all running instances.
Linker error undefined reference to qt_static_metacall
QML module wasn't picked up by qt_add_qml_module. Make sure your qml/CMakeLists.txt's qt_add_qml_module(<target> URI <Pascal> …) includes every .qml file via QML_FILES.
AppImage launches but immediately exits / "no DISPLAY"
CI / headless runs — the smoke harness sets QT_QPA_PLATFORM=offscreen. For interactive use you need a display. From a headless server, xvfb-run -a ./MyApp-x86_64.AppImage or ssh -X.
If you hit something not on this list, the failure mode is usually visible in either var/log/dev.log (Symfony) or the terminal make dev is running in (FrankenPHP / Qt). Open an issue with both pasted in.