Files
php-qml/docs/getting-started.md
magdev beb4e3ab9d docs: refresh README + docs/ for v0.2.0
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>
2026-05-03 22:27:52 +02:00

11 KiB
Raw Permalink Blame History

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.56.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:

  1. The status dot in the toolbar flips to green within ~1 s — connection state is Online.
  2. The Mercure dot turns green — SSE subscription is live.
  3. Click Ping — round-trip via /api/ping returns; 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-id if you prefer auto-incrementing integers.
  • #[BridgeResource] attribute on the entity. The bundle's Doctrine subscriber sees this and auto-publishes postPersist / postUpdate / postRemove to two Mercure topics:
    • app://model/todo — collection topic, watched by ReactiveListModel.
    • app://model/todo/<id> — entity topic, watched by ReactiveObject.
  • /api/<name> CRUD controllerGET /api/todos, POST /api/todos, GET/PATCH/DELETE /api/todos/<id>.
  • <Name>List.qml — a starter ListView bound to a ReactiveListModel doing 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.

  • Architecture — what's actually happening when make dev runs.
  • Reactive models — how ReactiveListModel decides to upsert / delete / re-fetch.
  • Update semantics — when optimistic mutations roll back; what pending means.
  • 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. Check tail -f symfony/var/log/dev.log and the terminal make dev is running in.
  • 401 on /api/* — the host is sending the wrong bearer. In dev that's BRIDGE_TOKEN from .env; the Qt host reads it via BackendConnection.token which defaults to qgetenv("BRIDGE_TOKEN").
  • Port 8765 already taken — another make dev is still running. pkill -f frankenphp and 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.