Files
php-qml/spike
magdev 9655b6fef9 Add Phase 0 spike: end-to-end transport verified
Bare PHP behind FrankenPHP plus a Qt/QML host that spawns it. GET /api/ping
publishes a Mercure event; the QML window receives it back over the SSE
stream. Findings (Caddy directive ordering, Mercure transport scalar,
PR_SET_PDEATHSIG for child cleanup, PHP 8.5 curl_close deprecation, port
collision with system FrankenPHP, pure-QML SSE viability) are recorded in
spike/README.md so Phase 1 starts from a known-good baseline.

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

Phase 0 — Throwaway transport spike

The smallest thing that proves the php-qml architecture's transport works end-to-end on Linux.

A Qt/QML host spawns a bundled FrankenPHP process, hits GET /api/ping over local HTTP, and subscribes to Mercure SSE. Clicking "Ping" makes a request whose handler also publishes a Mercure event — that event then arrives back in the QML window via the SSE stream.

Lifetime of this directory is short: it gets removed when Phase 1's framework skeleton supersedes it. See PLAN.md §13.

Run it

Prerequisites on the dev machine:

  • Linux (tested on openSUSE Tumbleweed)
  • cmake, qt6-base-devel, qt6-declarative-devel, qt6-quickcontrols2-devel, qt6-tools-devel, gcc-c++
  • curl (for the FrankenPHP download)

Fetch FrankenPHP if bin/frankenphp is missing:

curl -fsSL -o spike/bin/frankenphp \
  https://github.com/php/frankenphp/releases/download/v1.12.2/frankenphp-linux-x86_64
chmod +x spike/bin/frankenphp

Then from the repo root:

./spike/run.sh

The script builds the Qt binary on first run (into spike/build/) and launches it. The Qt host spawns FrankenPHP as a child; closing the window cleans both up.

What you should see

A window with two status dots — backend and Mercure — that flip green within a second of launch. Clicking Ping appends two lines to the event log: an outgoing → ping line with the response payload and timing, and an incoming ← event … line carrying the Mercure-pushed copy of the same payload. The two arrive within ~50 ms of each other on a quiet machine.

Killing the child manually from another terminal — pkill -f spike/bin/frankenphp — flips the status dots and the QML logs · mercure → disconnected. Re-running run.sh reconnects.

What's hardcoded

Thing Value
Backend URL http://127.0.0.1:8765
Mercure topic app://ping
Mercure JWT (publisher + subscriber) !ChangeThisDevKey! (anonymous subscribers are also enabled)
FrankenPHP version v1.12.2 (pinned in this README; bin/frankenphp is gitignored)
Port 8765 (chosen to avoid the system FrankenPHP that already binds 8080 on this box)

No auth on /api/ping. No Last-Event-ID resume. No optimistic updates. All of those land in Phase 1+.

Project layout

spike/
  Caddyfile          # FrankenPHP / Caddy / Mercure config
  run.sh             # build + run
  bin/frankenphp     # downloaded static binary (gitignored)
  php/
    index.php        # GET /api/ping → JSON pong + Mercure publish
  qt/
    CMakeLists.txt
    main.cpp         # Qt host: spawns frankenphp, installs SIGTERM handler, prctl(PR_SET_PDEATHSIG)
    Main.qml         # window UI: status dots, Ping button, event log
    Mercure.qml      # pure-QML SSE client over XMLHttpRequest

Findings worth carrying into Phase 1

  • The mercure Caddy directive is site-level, not global. It goes inside the site block alongside php_server, not in the {} global block.
  • The transport directive is now scalar, not URL. transport_url local://local is deprecated in FrankenPHP 1.12; use transport local.
  • A default Mercure config requires a transport. Without one, FrankenPHP falls back to bolt and demands a database file, which fails with invalid transport: timeout on first boot. For dev / spikes, transport local (in-memory) is the right choice.
  • Subprocess cleanup needs both belt and suspenders. Qt's aboutToQuit only fires for graceful exits; for SIGTERM we need an explicit signal handler that calls QCoreApplication::quit(). As a Linux safety net, prctl(PR_SET_PDEATHSIG, SIGTERM) in the child via QProcess::setChildProcessModifier() ensures the kernel reaps the child even if the parent crashes.
  • PHP 8.5 deprecates curl_close(). It's been a no-op since 8.0; spike code now relies on the variable going out of scope. Worth scrubbing for in Phase 1's PHP code style rules.
  • The system can have a pre-existing FrankenPHP on :8080. Hardcoded port choice for the spike is :8765; for the framework proper, pick the port at runtime per session (§3, Startup) or use a unix socket on Linux/macOS.
  • Pure-QML SSE works. XMLHttpRequest in QML 6.11 does deliver partial responses during readyState === LOADING, so Mercure.qml parses the stream without a C++ helper. The C++ MercureClient planned in PLAN.md §7 is still the right call for Phase 1 (reconnect, Last-Event-ID, backpressure), but it's not blocked on a Qt limitation.
  • JWT minting in plain PHP is ten lines. Symfony's mercure-bundle is convenience, not necessity — useful to know for the Phase 1 SessionAuthenticator.

Out of scope for this spike

(All deferred to Phase 1+, see PLAN.md §13.)

  • Symfony, Doctrine, any framework
  • Per-session secret, real auth
  • Last-Event-ID resume / reconnect with backoff
  • Optimistic updates, pending role, connectionState enum
  • Single-instance lock, deep links
  • Packaging, auto-update, code signing
  • Tests (manual run only)