Files
php-qml/framework/skeleton
magdev de4a14da36 v0.2.0 (13/N): qmltestrunner harness + CI wiring + close out v0.2.0 plan
Closes the testing-strategy row of PLAN.md §13 v0.2.0 and parks the
two remaining items with rationales.

Shipped:

- framework/qml/tests/{CMakeLists.txt, main.cpp, tst_smoke.qml}
  Qt Quick Test scaffold: QUICK_TEST_MAIN bootstrap + one smoke test
  proving the harness loads. New tests land as tst_<feature>.qml in
  the same dir; qmltestrunner auto-discovers them. Built only when
  -DBUILD_TESTING=ON (production AppImages stay clean).
- skeleton + example/todo Makefiles: `make qmltest` target invokes
  the configure → build → ctest dance. `make quality` now depends
  on qmltest.
- .gitea/workflows/ci.yml: `QML unit tests` step after qmllint in
  the Quality job. Out-of-tree build dir (build-tests) so the
  CTest run doesn't pollute the cached release build.

Verified locally: configure + build + ctest pass, both smoke
assertions pass, runs in 0.5s.

Closed in PLAN.md §13 v0.2.0 with rationale (no code change):

- Build-time Symfony cache warmup → moved to v0.3.0. The obvious
  approach (cache:warmup at build, copy at first launch) doesn't
  save any time because Symfony bakes absolute kernel.project_dir
  into the compiled cache, and the AppImage's FUSE mount path
  changes every launch — every cached path is stale on launch N+1.
  Doing it properly requires virtualising getProjectDir(), symlink
  fix-up, multi-app namespacing — its own minor's worth of design.
- ReactiveObject cursor pagination → closed N/A. ReactiveObject
  already has pending / invoke() / Idempotency-Key correlation /
  version-gap detection at parity with ReactiveListModel; the only
  feature it lacks is *pagination*, which is meaningless for a
  single-entity model.

That fully closes the v0.2.0 plan as documented. Remaining v0.2.0
items in PLAN.md §13 are the audit-ends already shipped earlier in
the cycle (interfaces / BridgeOp / BridgeBundleInfo / Maker DRY /
--with-dto / port negotiation / pre-migration backup / bridge:export
/ periodic auto-update / native-dialogs doc / event maker /
read-model maker / qmltestrunner) plus the two parked items
documented above. Ready to tag when the user gives the word.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 21:02:30 +02:00
..

php-qml skeleton

The framework's reference application: a minimal Symfony backend plus a Qt/QML host, wired together exactly the way php-qml/bridge is intended to be used. Use this as a starting point or copy parts into your own project.

Prerequisites

  • Linux (other platforms land in Phase 4)
  • Qt 6.5+ dev packages (qt6-base-devel, qt6-declarative-devel, qt6-quickcontrols2-devel, qt6-tools-devel), CMake, gcc-c++
  • PHP 8.4+ (Symfony 8 enforces this)
  • FrankenPHP on PATH (or set FRANKENPHP=/path/to/frankenphp)
  • Composer

First run

make install        # composer install in symfony/
make build          # cmake + qt host
make doctor         # bridge:doctor — readiness check
make dev            # FrankenPHP --watch + Qt host, tears both down on Ctrl-C

make dev opens a Qt window, status dots flip to green, and the Ping button round-trips through /api/ping and back via Mercure SSE.

Adding a reactive resource

The headline workflow — add a Doctrine entity that ends up reactive in QML with three commands and zero handwritten glue:

cd symfony

# 1) Generate entity + controller + QML snippet
bin/console make:bridge:resource Todo
#   created: src/Entity/Todo.php          # #[BridgeResource] + UUIDv7
#   created: src/Controller/TodoController.php
#   created: ../qml/TodoList.qml

# 2) Schema migration (Doctrine reads the entity)
bin/console make:migration
bin/console doctrine:migrations:migrate -n

# 3) Use the generated TodoList.qml from your QML window.

That's it. The bundle's Doctrine subscriber automatically dual-publishes postPersist/postUpdate/postRemove events to:

  • app://model/todo (collection topic, watched by ReactiveListModel)
  • app://model/todo/{id} (entity topic, watched by ReactiveObject — Phase 3)

The ReactiveListModel in TodoList.qml does an initial GET /api/todos, subscribes to the collection topic, and applies diffs to its rows as they arrive. Adding --int-id switches the maker to auto-incrementing integer IDs.

Verifying end-to-end

With make dev running, post a todo from another terminal and watch it appear:

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}'

The Mercure SSE stream receives a correlationKey: my-key-1 envelope which the Qt host's ReactiveListModel matches against any in-flight optimistic mutation (PLAN.md §5).

Hot reload

Both halves of the app reload without re-running make dev.

PHP-side (Symfony / FrankenPHP)

make dev runs frankenphp run --watch (see Caddyfile and scripts/dev.sh). FrankenPHP rebuilds the worker on any change under symfony/ — controllers, services, entities, templates, configuration. Just save the file; the next request through the Qt host hits the new code. There is no opcache to clear and no service to restart.

If you change something Doctrine-mapped, run a fresh migration in another terminal:

cd symfony
bin/console make:migration
bin/console doctrine:migrations:migrate -n

The Qt host stays up across all of this.

QML-side

The Qt host loads QML from a compiled-in resource bundle, so saving a .qml file does not flip the running window automatically. Three workflows that do:

  • Qt Creator → File → Reload (or Ctrl+R with focus on a QML file). Rebuilds the QML cache and reloads the window in place.
  • qmlls live preview — the QML language server bundled with Qt 6.5+ runs a live preview connected to your editor (VSCode + the Qt extension, neovim, helix). Edits show up instantly in the preview window without rebuilding.
  • Run from source — start the host with QT_QUICK_CONTROLS_CONF= and QML_IMPORT_TRACE=1 set, and pass -DQT_QML_DEBUG so the running engine accepts a hot-reload connection from Qt Creator. PLAN.md §6 captures the long-term plan to gate this behind BRIDGE_DEV=1.

For most edits, Qt Creator's Reload is the lowest-friction option. The .qmlls.ini file (auto-generated when qmlls first runs) configures completion + live preview against this project's QML import paths.

Editor configs

Both .vscode/ and .idea/runConfigurations/ ship with the skeleton.

VSCode (.vscode/launch.json):

  • Listen for Xdebug — attaches the debugger on port 9003 once you set XDEBUG_MODE=debug for the FrankenPHP child (e.g. XDEBUG_MODE=debug make dev).
  • Run skeleton (Qt host) — gdb-launches the built binary with BRIDGE_URL=http://127.0.0.1:8765 so it talks to the dev mode FrankenPHP started elsewhere by make dev.
  • Compound: Dev: Xdebug + Qt host — runs both at once.

PhpStorm (.idea/runConfigurations/): make dev, make doctor, make quality shell run configs. PHP debugging is via the toolbar's Start Listening for PHP Debug Connections toggle (PhpStorm's Xdebug listener is global, not per-project).

Dev console

`Ctrl+`` toggles an in-window console showing the bundled FrankenPHP child's stdout + stderr (PLAN.md §8). It's a passive ring buffer (~500 lines) — opening it has no IPC cost. Use it when you don't have a separate terminal to read the dev log.

Quality checks

make quality        # PHPStan + php-cs-fixer (check) + PHPUnit + qmllint

The same set CI runs (.gitea/workflows/ci.yml).

Layout

skeleton/
  Caddyfile         # FrankenPHP / Mercure config (dev mode)
  Makefile          # build / dev / doctor / quality
  scripts/dev.sh    # process-group teardown for FrankenPHP + Qt host
  symfony/          # the Symfony app (composer project)
    config/
    src/Entity/     # filled by `make:bridge:resource`
    src/Controller/ # filled by `make:bridge:resource`
    public/index.php
    bin/console
  qml/              # the Qt host
    CMakeLists.txt
    main.cpp
    Main.qml
    <Name>List.qml  # filled by `make:bridge:resource` (relative to symfony/)

Where to go next

  • The framework code lives one level up: framework/php (the bundle) and framework/qml (the Qt module). Both are linked into this skeleton via Composer's path repository / CMake's add_subdirectory, so edits there are picked up live.
  • The full design story is in PLAN.md.
  • Phase 3 wires this skeleton into a real POC todo application with a multi-window test and packaging-CI dry runs.