README is now tight and link-heavy: 60-second tour, then deep links into docs/. The wall of detail moved out. docs/ covers the framework end-to-end: - getting-started.md — prerequisites by distro (Tumbleweed, Fedora, Debian/Ubuntu, Arch), full first-run walkthrough, troubleshooting. - architecture.md — process pair, transport, dev/bundled mode. - update-semantics.md — state machine + optimistic mutations + key round-tripping. - reactive-models.md — ReactiveListModel, ReactiveObject, Mercure dual-publish. - makers.md — make:bridge:resource/command/window. - dev-workflow.md — hot reload (PHP + QML), dev console, editor configs, bridge:doctor, snapshot/integration test loops, perfsmoke. - bundled-mode.md — supervisor, per-session secret rotation, first-launch migrations, auto-update wiring. - packaging-linux.md — make appimage, build-appimage.sh CLI, AppImageUpdate sidecar, perfsmoke budgets, release CI, bundle-size breakdown. - qml-api.md / php-api.md — exhaustive symbol reference with all Q_PROPERTY/Q_INVOKABLE/signals + every public PHP service / attribute / command. - configuration.md — every env var (host, Symfony, dev script, packaging script, perfsmoke), every CLI flag (php-qml-init, build-appimage.sh), make targets, default ports/paths. 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://gitea.example/<you>/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.
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.