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>
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
mercureCaddy directive is site-level, not global. It goes inside the site block alongsidephp_server, not in the{}global block. - The
transportdirective is now scalar, not URL.transport_url local://localis deprecated in FrankenPHP 1.12; usetransport local. - A default Mercure config requires a transport. Without one, FrankenPHP falls back to
boltand demands a database file, which fails withinvalid transport: timeouton first boot. For dev / spikes,transport local(in-memory) is the right choice. - Subprocess cleanup needs both belt and suspenders. Qt's
aboutToQuitonly fires for graceful exits; forSIGTERMwe need an explicit signal handler that callsQCoreApplication::quit(). As a Linux safety net,prctl(PR_SET_PDEATHSIG, SIGTERM)in the child viaQProcess::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.
XMLHttpRequestin QML 6.11 does deliver partial responses duringreadyState === LOADING, soMercure.qmlparses the stream without a C++ helper. The C++MercureClientplanned 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-bundleis 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-IDresume / reconnect with backoff- Optimistic updates,
pendingrole,connectionStateenum - Single-instance lock, deep links
- Packaging, auto-update, code signing
- Tests (manual run only)