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>
19 KiB
Changelog
All notable changes to this project are documented here.
The format is based on Keep a Changelog and this project adheres to Semantic Versioning. Pre-v1.0.0, minor bumps may break public API.
Unreleased
This section tracks work landing on dev toward v0.2.0 (next minor; pre-1.0 SemVer permits API breaks). See PLAN.md §13 for the full v0.2.0 scope.
Added
PublisherInterface,ModelPublisherInterface,CorrelationContextInterface. The bridge's three public services now ship as interfaces (same namespace as concrete, mirroring upstreamHubInterface/Hub). App controllers and listeners should typehint these instead of the concrete classes so swappable implementations (offline-buffer publisher, request-stamp correlation context, etc.) remain non-breaking. ExistingPublisher/ModelPublisher/CorrelationContextclasses implement the new interfaces unchanged.BridgeOpenum. PHP 8.1 string-backed enum (Upsert/Delete/Replace/Event) replacing the raw'upsert'/'delete'strings previously passed betweenDoctrineBridgeListenerandModelPublisher::publishEntityChange. Values match PLAN.md §4's envelopeopwire format. Typo'd ops are now caught at the type level instead of silently producing envelopes clients ignore.BridgeBundleInfovalue object carrying the bundle's name + class FQCN.HealthControllernow constructor-injects this instead ofPublisherInterfaceas the deep-load canary, so the readiness probe is no longer coupled to the publisher's contract./healthzresponse gains anamefield (php-qml/bridge); thebundlefield now reportsPhpQml\Bridge\BridgeBundle(wasPhpQml\Bridge\Publisher).Maker\Support\NameInput— shared interactive name prompt. All threemake:bridge:*makers (resource,command,window) re-implemented the same "prompt, trim, ucfirst, reject empty" closure inline; collapsed into one call site so empty-argument and validation behaviour stay in lockstep.Maker\Support\Naming—camelTo($name, $separator)helper. Replaces inlinepreg_replace('/(?<!^)[A-Z]/', $sep.'$0', $name)regex copies (BridgeResourceMaker emits_-joined route plurals, BridgeCommandMaker emits--joined kebab slugs).make:bridge:resource --with-dtoopt-in. GeneratesCreate<Name>Dto+Update<Name>Dtoundersrc/Dto/alongside the controller, and the controller dispatches via#[MapRequestPayload]. Closes the input-validation gap from the audit: malformed JSON, missing required fields, or#[Assert\NotBlank]violations now produce RFC 7807application/problem+jsonautomatically (Symfony'sRequestPayloadValueResolver) — no moreif (isset($data['title']))boilerplate, no silent type coercion. Update DTOs use nullable defaults so PATCH callers send only the fields they want changed. Without--with-dtothe legacy template still ships unchanged. Maker fails loud ifsymfony/validatorisn't autoloadable. Skeleton + example/todo composer.json pullsymfony/validatorso scaffolded apps work out of the box. Snapshot test exercises both modes.make:bridge:event <Name>maker. Generates a domain-event class (src/Event/<Name>Event.php, readonly value object), a subscriber (src/EventSubscriber/<Name>Subscriber.php) that republishes viaPublisherInterfaceonapp://event/<kebab-name>, and a QML stub ({qml_path}/<Name>EventHandler.qml) that listens viaMercureClientand re-emits as a typedsignal. Closes the third row of PLAN.md §8's makers table; pairs with the existingmake:bridge:resource/command/windowmakers so domain events have a one-command path from PHP through to QML.make:bridge:read-model <Name>maker. Generates a query-only projection:src/ReadModel/<Name>ReadModel.php(query service stub injectingEntityManagerInterface),src/Controller/<Name>Controller.php(single GET handler at/api/<kebab-plural>), and{qml_path}/<Name>List.qml(ReactiveListModelbound to the route, deliberately no Mercure topic — read-models aren't auto-reactive; invalidation is event-driven viamake:bridge:event). Closes the fourth row of PLAN.md §8's makers table.- Pre-migration auto-backup of
var/data.sqlite. Bundled-mode supervisor copies the SQLite file tovar/data.sqlite.<unix-timestamp>.bakbefore invokingdoctrine:migrations:migrate; trims to the 5 most recent. SQLite's lack of transactional DDL means a half-applied migration can corrupt the database with no rollback path; cheap insurance against that. Skipped on first launch (no DB to back up); failure to copy logs a warning and continues (a missing safety-net is not a reason to refuse to boot). Backup runs only in bundled mode — dev mode users own theirvar/data.sqlitelifecycle. Bundled-supervisor integration test gained an assertion that a.bakfile appears under the user data dir on second launch. bridge:exportconsole command + QML hook. Newbin/console bridge:export <destination>copies the active SQLite database to a user-chosen path (overwrites if the destination exists; reads the source path fromDATABASE_URLso it works in both dev and bundled mode). Mirrored on the QML side asBackendConnection.exportDatabase(path)(Q_INVOKABLE bool) returning success synchronously and emittingdatabaseExported(path)/databaseExportFailed(reason)for async UX. QML callers typically pair it withQt.labs.platform.FileDialog(seedocs/native-dialogs.md). 4 unit tests cover the command's success / non-SQLite-URL / missing-source / overwrite paths.- Periodic auto-update check. Bundled-mode supervisor arms an
AppImageUpdatepoll on the firstOnlinetransition: a launch-time check 10 s after backend ready, then a recurring check every 6 hours. PLAN.md §11 Auto-update called for "check on launch and once per N hours; offer install on next restart, never auto-restart" — the existingcheckForUpdates()Q_INVOKABLE remains the install trigger, this just automates the polling. Disable withBRIDGE_AUTO_UPDATE_DISABLE=1; override the period withBRIDGE_AUTO_UPDATE_PERIOD_MIN=<minutes>. Dev mode skips entirely. - Bundled-mode port negotiation. The hardcoded
m_port = 8765is replaced with a runtime-negotiated free ephemeral port: bind aQTcpServertoQHostAddress::LocalHostport 0, captureserverPort(), close, then hand the port to FrankenPHP via the existingPORTenv var (the Caddyfile already reads{$PORT:8765}). Two installed php-qml apps no longer collide on first launch — whichever loses the port-8765 race used to go Offline; now each picks its own. Test harnesses can pin the port viaBRIDGE_PORT=<n>for reproducibility (the existingbundled-supervisor.shandperfsmoke.shboth export it). Each launch also writes the chosen port tovar/bridge.portso any external tool that needs the runtime address can read it without parsing Qt's log. qmltestrunnerQML unit tests + CI wiring.framework/qml/tests/now ships a Qt Quick Test executable target (qml_unit_tests) discovered by CTest. Built only when configured with-DBUILD_TESTING=ONso production AppImages don't carry it. One smoke test (tst_smoke.qml) proves the harness; future per-feature tests land beside it astst_<feature>.qml. Wired intomake qmltest(skeleton + example/todo) and into the Gitea ActionsQualityjob after qmllint.
Changed
ModelPublisher::publishEntityChange()signature:string $op→BridgeOp $op. Pre-1.0 SemVer break. Internal callers updated; external callers (rare) need to migrate from raw strings to enum cases.- Internal typehints switched to interfaces.
ModelPublisherconstructor takesPublisherInterface+CorrelationContextInterface;DoctrineBridgeListenertakesModelPublisherInterface;HealthControllertakesBridgeBundleInfo; the skeleton'sPingControllertakesPublisherInterface. Autowire continues to inject the concrete implementations transparently. /healthzresponse shape.bundlefield's value changes fromPhpQml\Bridge\PublishertoPhpQml\Bridge\BridgeBundle; newnamefield reports the Composer package name. JSON consumers ignoring unknown keys are unaffected; consumers asserting thebundlevalue need to migrate.
Fixed
ModelPublisher::extractIdreflection cleanup. Removed thesetAccessible(true)call (deprecated since PHP 8.1; all properties are accessible via Reflection without it).
Tests
BridgeOpTestwire-format contract. Locks the four enum case values (upsert/delete/replace/event) against accidental rename — QML clients hardcode the strings, so avaluechange is a wire-protocol break and the test fails the build before it ships.
Documentation
docs/native-dialogs.md. Documents the framework boundary §12 already implied: native UI affordances (file pickers, confirmations, system notifications) live on the QML side viaQt.labs.platform, not in PHP. Includes copy-pasteable examples ofFileDialog,MessageDialog,SystemTrayIcon, and the trigger-vs-effect split for server-pushed-event-driven dialogs.
0.1.2 — 2026-05-03
Bugfix release. Bundles the v0.1.1 follow-up that surfaced during the v0.1.2 cycle (bundled-mode supervisor cleanly SIGTERMs its child on host exit) with three non-breaking fixes from a post-v0.1.1 architecture audit.
Fixed
- Bundled supervisor: clean child shutdown.
BackendConnection's destructor was the only path that calledteardownChild(), but it ran during stack unwinding afterapp.exec()returned — by then the Qt event loop was already mid-shutdown andQProcess::waitForFinishedcouldn't reliably reap the child. Symptom: Qt loggedQProcess: Destroyed while process ("...frankenphp") is still running, frankenphp + its PHP workers became orphans. The constructor now also connectsQCoreApplication::aboutToQuit→teardownChild, so the child is SIGTERM'd while the event loop is still active. The bundled-supervisor integration test gained a clean-shutdown assertion (no Qt warning + no orphan frankenphp under the host's PGID after SIGTERM). bridge.qml_pathis now actually configurable. TheBridgeResourceMakerandBridgeWindowMakerdocstrings claimed the QML scaffold path was settable via the bundle'sqml_pathoption, but the bundle'sconfigure()was empty and the constructor default ('../qml/') was the only knob.BridgeBundle::configurenow defines aqml_pathscalar node;loadExtensionexposes it as thebridge.qml_pathcontainer parameter;services.yamlbinds it into both makers. Apps can override withconfig/packages/bridge.yaml:bridge: { qml_path: ../qml/ }. Default unchanged.SessionAuthenticator: problem+json on the entry-point path.onAuthenticationFailurealready returned RFC 7807application/problem+jsonfor bad-token requests, but Symfony's defaultAuthenticationEntryPointInterface::startfired for no-token requests, returning a Form-flavoured 302/401 with the wrong shape for QML'sRestClienterror mapping. The authenticator now implementsAuthenticationEntryPointInterfaceand routes both paths through a sharedproblemJson()helper so QML sees one error shape regardless of which firewall path was taken. New test covers the entry-point response.CorrelationKeyListener::onTerminatesub-request guard.onRequestalready guarded withisMainRequest(), butonTerminatecleared unconditionally — a sub-request finishing mid-controller would wipe the main request's correlation key, causing the matching Mercure echo to lose itscorrelationKeyfield and the optimistic UI to never reconcile. FrankenPHP worker mode does not currently emit sub-requests so the user-visible impact is nil, but the asymmetry was a defensive bug.
0.1.1 — 2026-05-03
Bugfix release closing the four follow-ups identified during the v0.1.0 shakedown. No new public API surface; /healthz response gains an additive bundle field (existing JSON consumers ignore unknown keys).
Fixed
- Wipe Symfony cache on bundled-mode launch. Symfony's compiled container bakes
kernel.project_diras an absolute path. In bundled mode that path lives inside the AppImage's FUSE mount (/tmp/.mount_<random>), which is regenerated every launch. So the cache from launch N referenced mount-N's path; launch N+1 (different mount) hitInvalidDirectoryfrom doctrine-migrations on the first launch-2 (and similar at any kernel.project_dir-sensitive lookup).BackendConnection::initBundledModenowrmdirs the cache before each spawn. Costs ~1-2s of warmup per launch; build-time cache warmup is the permanent fix (PLAN.md §13 v0.2.0). The bundled-supervisor integration test gained a 2nd-launch-from-fresh-staging step so this regresses if forgotten. HealthControllerdeep-loads the bundle. Constructor-injectsPublisherso/healthzreturns 200 only whenBridgeBundleis fully container-resolvable. v0.1.0's/healthzreturned 200 against half-loaded bundles — both the path-repo symlink dangling at runtime and the read-only-cache failure shipped green through perfsmoke as a result. Response body now includesbundle: "PhpQml\\Bridge\\Publisher"as the canary value.- Caddyfile formatting.
framework/skeleton/Caddyfileandexamples/todo/Caddyfilereformatted withcaddy fmt. The "Caddyfile input is not formatted; run 'caddy fmt --overwrite'" warning that fired on every FrankenPHP boot is gone.
Added
- Bundled-mode supervisor integration test (
examples/todo/tests/bundled-supervisor.sh,make integration-bundled). Stages a fake AppImage layout in/tmp(host binary copied — Qt'sapplicationDirPath()dereferences symlinks via/proc/self/exe, so the real layout has to be mimicked closely; staged Symfony tree ischmod -R a-wto actually exercise the read-only-mount cache redirect) and exercises the supervisor end-to-end without needing a real.AppImagebuild. Asserts/healthzdeep-load + cache redirect. Wired into.gitea/workflows/ci.ymlafter the existing dev-mode integration test. - Skeleton AppImage parity.
framework/skeleton/Makefilegainsstaging-symfony+appimagetargets mirroringexamples/todo/Makefile's. Newframework/skeleton/packaging/skeleton.{desktop,png}provide minimal AppImage assembly inputs.bin/php-qml-initnow: (a) renames packaging files to match the scaffolded app name, (b) rewrites the.desktopfile'sName/Exec/Icon, (c) substitutes the newBUNDLE_SRCandPACKAGINGMakefile variables to either absolute framework paths (default) or vendored.bridge/.bridge-packagingpaths (--vendor). Scaffolded apps inheritmake appimageworking out of the box.
Notes
BackendConnection::m_portstays hardcoded to 8765 — port-collision between two installed php-qml apps is a real bug surfaced during v0.1.1 prep, but the fix touches every consumer that hardcodes 8765 (perfsmoke, the new bundled-supervisor test) so it's tracked as a v0.2.0 item rather than a v0.1.x bugfix.
0.1.0 — 2026-05-03
First public preview. Phases 0 through 4a in PLAN.md are complete plus the Phase 5 DX-polish sub-commits. Linux is the only packaged target; macOS and Windows are deferred to 4b / 4c. Tagging is the user's call (release CI runs on v* tags).
Added
- Process-pair architecture. Qt/QML host owns rendering; bundled FrankenPHP child runs the Symfony app in worker mode. They communicate via local HTTP and Mercure SSE.
- Symfony bundle (
php-qml/bridge).BridgeBundlewires Doctrine subscriber,ModelPublisher,bridge:doctorconsole command, and the#[BridgeResource]attribute so app code stays idiomatic Symfony. - Qt module (
PhpQml.Bridge).BackendConnection(lifecycle + Update Semantics state machine: Connecting / Online / Reconnecting / Offline),RestClient,MercureClient,ReactiveListModel,ReactiveObject,AppShell,SingleInstance(QLocalServer-backed lock with launch-arg forwarding),DevConsole. - Update Semantics. Optimistic mutations with
Idempotency-Keyround-tripped to Mercure ascorrelationKey; in-flightpendingrole; offline overlay + reconnecting banner viaAppShell. - Headline makers (
symfony/maker-bundle):make:bridge:resource <Name>— entity (#[BridgeResource]+ UUIDv7 by default,--int-idfor auto-increment), CRUD controller, starter<Name>List.qml.make:bridge:command <Name>— controller stub for non-CRUD endpoints.make:bridge:window <Name>— second-window QML scaffold.
- Skeleton application (
framework/skeleton) — minimal reference app exercised by every CI job. - POC todo app (
examples/todo) — full list UI, multi-window mirror, mark-all-done command, end-to-end test of multi-window coherence and crash-recovery. - bundled mode. When
BRIDGE_URLis unset (typical AppImage case),BackendConnectionspawns the embedded FrankenPHP, generates a per-session bearer token, runs first-launch migrations into~/.local/share/<app>/var/data.sqlite, and supervises the child withprctl(PR_SET_PDEATHSIG, SIGTERM)for cleanup safety. - Linux AppImage packaging.
packaging/linux/build-appimage.sh+make appimageproduce a single ~150 MB binary (Qt + Symfony + FrankenPHP + AppImageUpdate sidecar). - AppImageUpdate auto-update. Embedded
update-infoELF section points at the canonical Gitea Releases URL.BackendConnection.checkForUpdates()/applyUpdate()invoke the bundled sidecar. - Release CI (
.gitea/workflows/release.yml). Triggers onv*tags. Builds the AppImage, runstests/perfsmoke.shagainst PLAN.md §11 budgets (bundle ≤ 200 MB, cold start ≤ 4 s on shared CI runners, idle RSS ≤ 200 MB), generates zsync metadata +latest.jsonappcast +SHA256SUMS, optionally GPG-signs them, and uploads everything to the Gitea Release. - Quality CI (
.gitea/workflows/ci.yml). PHPStan + php-cs-fixer (check) + PHPUnit + qmllint + maker snapshot test + bridge-integration test (HTTP/SSE round-trip + crash-recover) on every push tomain. - DX polish (Phase 5):
DevConsole.qml— opt-in window into the bundled FrankenPHP child's merged stdout/stderr; 500-line ring buffer; `Ctrl+`` toggles it in skeleton + todo example.bin/php-qml-init <name>— bash scaffolder. Copiesframework/skeleton/, rewrites identifiers, repoints the path-composer-repo and CMakeadd_subdirectory(framework/qml)reference, runscomposer installand migrations.--vendorproduces a portable copy..vscode/launch.json+tasks.json+settings.jsonand.idea/runConfigurations/shipped with skeleton and todo example.- Hot-reload story documented end-to-end (FrankenPHP
--watch, Qt Creator Reload,qmllslive preview).
Notes
- Tooling versions enforced: PHP 8.4+, Symfony 8, Doctrine ORM 3, Qt 6.5+, FrankenPHP 1.12.2.
- The bundle ships without
composer.lock(it's a library); the skeleton and the todo example carry their own. - Licensed under LGPL-3.0-or-later (
LICENSE+LICENSE.GPLat the repo root). Chosen to align with Qt 6's LGPLv3 licensing — see PLAN.md §12 for the relinkability obligations the AppImage build already honours.