20 Commits
v0.1.2 ... dev

Author SHA1 Message Date
c673ec22e2 qml: defer ReactiveListModel/ReactiveObject initial fetch to componentComplete()
setBaseUrl() and setSource() used to fire refresh() inline as soon as
both `baseUrl` and `source` were populated — but setToken() never
triggered a refresh. QML evaluates literal property assignments before
bindings to other objects' properties, so a model declared with
literal `source` plus bindings to `BackendConnection.url` /
`BackendConnection.token` (the exact shape of make:bridge:window's
output) could fire its GET *before* the `token` binding had landed.
The unauthenticated request hit Symfony's SessionAuthenticator, came
back 401, and the model parked at `ready === false` with an empty
list. Mercure subscribed anonymously (the model explicitly clears the
SSE client's bearer), so subsequent server-side mutations propagated
fine — masking the initial-fetch failure as "list is empty until
something changes". Hit by the second window in examples/todo.

Both classes now implement QQmlParserStatus and trigger the initial
refresh from componentComplete(), where every binding (literal *and*
singleton-derived) is guaranteed to have landed. After completion,
individual setter changes still trigger refresh inline — so token
rotation / URL reassignment after first load behave unchanged.

Regression test under framework/qml/tests/tst_reactive_list_model.qml
using the v0.2.0 qmltestrunner harness. Adds a TestHttpServer helper
that mimics SessionAuthenticator's 401-on-no-bearer behaviour so the
regression is observable; verified the test fails against the unfixed
production code (`Actual: ""` vs `Expected: "Bearer testtoken"` on
the captured Authorization header).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 19:19:56 +02:00
a43b440b20 docs: refresh README status + roadmap, add 'What you get' section
The Status note still pointed at the dead Phase-4b/4c framing; the
Roadmap was a mix of legacy phase numbering and version numbers and
didn't reflect that macOS/Windows/Flathub/Snap have been consolidated
into a single v0.9.0 cross-platform packaging push (PLAN.md §13). Drop
phases entirely, list each shipped/upcoming SemVer version, and pull
v0.3.0 (i18n, persistent logs, cache warmup) and v0.9.0 forward so the
roadmap matches what's actually planned.

Add a 'What you get' section between 'What it is' and the 60-second
tour with concrete numbers (bundle size, cold start, idle RSS) and the
shipped capabilities (five makers, reactive models, supervisor
hardening, self-update, DX tooling, CI surface) so the README has more
substance than just an architecture description.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 18:35:54 +02:00
28af802e9c gitignore: ignore framework/qml/build-tests/
The make qmltest target writes its CMake build tree to build-tests/
(deliberately distinct from the regular build/ tree so a configured-with
-DBUILD_TESTING=ON tree doesn't shadow production builds). The existing
build/ patterns don't match it, so it kept showing up as untracked.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 22:29:49 +02:00
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
340f2881d0 ci: run qmltestrunner with offscreen Qt platform
All checks were successful
CI / Quality (push) Successful in 5m31s
Release / Linux AppImage (push) Successful in 5m16s
Headless CI runner has no X display, so qmltest aborts loading the
xcb plugin. Set QT_QPA_PLATFORM=offscreen for the ctest invocation
in CI and in both Makefile qmltest targets so local headless runs
work too.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 21:13:35 +02:00
427dbae656 release prep v0.2.0
Some checks failed
CI / Quality (push) Failing after 4m28s
Release / Linux AppImage (push) Has been cancelled
- CHANGELOG.md: convert [Unreleased] to [0.2.0] — 2026-05-03 with
  release narrative + link reference at the foot of the file. Empty
  [Unreleased] re-seeded for the next cycle.
- PLAN.md: top status line gains v0.2.0 (shipped 2026-05-03);
  §13 v0.2.0 entry rewritten as the shipped recap (matches the
  v0.1.0 / v0.1.1 / v0.1.2 narrative-then-bullet style) plus the
  three carried-forward items with their parking rationales.

No code changes — release narrative + link plumbing only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 21:06:20 +02:00
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
6939278857 v0.2.0 (12/N): bundled-mode port negotiation
PLAN.md §13 v0.2.0 *Bundled-mode port negotiation*. Hardcoded
m_port = 8765 used to fail loudly only when a second php-qml app
launched on the same machine — whichever lost the bind race went
Offline with no recovery path.

Fix:
- Bind a transient QTcpServer to QHostAddress::LocalHost port 0,
  read serverPort(), close. Linux's ephemeral-port allocator
  doesn't immediately reassign the closed port, and FrankenPHP's
  bind happens within milliseconds inside spawnChild() — small
  TOCTOU window in theory, fail-loud in practice if it ever races.
- BRIDGE_PORT env override pins the port for tests / dev
  (bundled-supervisor.sh and perfsmoke.sh now both export it
  instead of the previous PERF_BACKEND_PORT-only knob).
- writePortSentinel() drops the chosen port to
  $XDG_DATA_HOME/<app>/var/bridge.port so external tools can read
  the runtime address without parsing Qt's log output.

Caddyfile already supported {$PORT:8765} env interpolation, so
no template churn. MERCURE_URL is computed from m_url which is
re-derived from the chosen port — no .env changes needed for
bundled mode (dev mode .env still references :8765 since the
developer controls their own frankenphp invocation).

bundled-supervisor.sh integration test gained a sentinel-file
assertion: after first launch, $USER_DATA/var/bridge.port must
exist and contain BRIDGE_PORT.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:55:20 +02:00
82de6cae36 v0.2.0 (11/N): periodic auto-update check
PLAN.md §11 *Auto-update* described "check on launch and once per N
hours; offer install on next restart, never auto-restart". v0.1.0
shipped checkForUpdates() and applyUpdate() Q_INVOKABLEs but only
manual triggers — no scheduling. This wires the polling.

armAutoUpdateOnFirstOnline() runs from setState(Online) in bundled
mode:
- A QTimer::singleShot fires checkForUpdates() 10 s after the first
  Online transition (lets cold-boot bandwidth/CPU settle first).
- A recurring QTimer fires checkForUpdates() every 6 hours after
  that.
- One-shot guard via m_autoUpdateArmed so reconnect cycles don't
  re-arm the timers.

Dev mode skips entirely (developers don't want their `make dev`
workflow polling AppImageUpdate). Env-var knobs:
- BRIDGE_AUTO_UPDATE_DISABLE=1 — skip entirely (respect-opt-out
  baseline; user-facing settings UI can layer on top later).
- BRIDGE_AUTO_UPDATE_PERIOD_MIN=<minutes> — override the period
  (handy for testing or shorter intervals on power-user opt-in).

The actual install (apply + restart) stays manual — never auto-
restart, per PLAN.md's UX rule. checkForUpdates emits
updatesAvailable(); QML decides whether/when to show a banner and
call applyUpdate().

Verified locally with QT_LOGGING_RULES=phpqml.bridge.bundled.info=true:
"phpqml.bridge.bundled: auto-update armed: launch check in 10000 ms,
period 360 min" appears in the host log after the BackendConnection
probe sees /healthz=200.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:50:59 +02:00
da097051ca v0.2.0 (10/N): bridge:export console command + QML hook
PLAN.md §12 *Data backup / export* called for "a bridge:export console
command and a UI hook for backup-to-file from v1". Shipping both now
so the v0.2.0 surface has the data-portability story end-to-end:

PHP side — `bin/console bridge:export <destination>`:
- Reads the source path from DATABASE_URL so it works in dev mode
  (developer's source-tree var/data.sqlite) and bundled mode (user
  data dir SQLite) without environment-aware logic.
- SQLite-only by design (PLAN.md §6 — single-instance SQLite-first);
  emits a clear error for non-sqlite:// URLs rather than pretending
  to support drivers that need driver-specific dump tooling.
- Overwrites the destination if it exists (the FileDialog or shell
  redirect that produced the path has already confirmed).
- 4 unit tests: happy path, non-SQLite URL, missing source,
  overwrite. Test count 24 → 28.

QML side — Q_INVOKABLE BackendConnection.exportDatabase(path):
- Bundled mode only; dev mode emits databaseExportFailed and
  returns false (developers own their SQLite directly).
- Accepts both filesystem paths and `file://` URLs (FileDialog
  results).
- Returns synchronously with bool but also emits async signals
  databaseExported(dst) / databaseExportFailed(reason) so QML
  can drive a snackbar / log without polling the return value.
- Removes any existing destination first (QFile::copy refuses
  to overwrite); the picker has already confirmed the choice.

Drive-by: parse_url() rejects sqlite:///abs/path on PHP 8.5+ (the
host-less triple-slash trips its strictness). Switched to a
prefix-strip — Doctrine DBAL only emits two URL shapes for
SQLite anyway (sqlite:///abs and sqlite://relative).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:36:51 +02:00
e0241bad64 v0.2.0 (9/N): pre-migration auto-backup of var/data.sqlite
PLAN.md §12 *Migrations on schema change* flagged this as a v1.0
prereq. SQLite has no transactional DDL — a half-applied migration
can corrupt the user's data with no rollback path. Cheapest defence
is a copy-aside before each migrate.

backupDatabase() runs at the head of runMigrations() in bundled
mode:
- skipped on first launch (no data.sqlite yet)
- copies var/data.sqlite to var/data.sqlite.<unix-timestamp>.bak
- trims to kMaxDatabaseBackups=5 most recent (mtime sort, oldest
  go first)
- copy failure logs a warning and continues; a missing safety-net
  is not a reason to refuse to boot

Dev mode is unaffected — developers own their var/data.sqlite
lifecycle and don't want a backup written every time `make dev`
restarts.

Integration test: bundled-supervisor.sh gained an assertion after
the 2nd-launch /healthz check that at least one
data.sqlite.*.bak file appears under the user data dir. Verified
locally — backup landed at the expected path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:31:50 +02:00
1d014ae3b7 v0.2.0 (8/N): make:bridge:read-model maker
Implements PLAN.md §8's fourth makers-table row. Read-models are
server-side projections — joined fetches, aggregates, denormalised
views — that QML reads without going through a writable
#[BridgeResource]. The maker emits:

  - src/ReadModel/<Name>ReadModel.php — query service stub injecting
    EntityManagerInterface; user fills query() with DQL / QueryBuilder
    / raw SQL as fits.
  - src/Controller/<Name>Controller.php — single GET handler at
    /api/<kebab-plural>, just normalises the read-model output to JSON.
  - {qml_path}/<Name>List.qml — ReactiveListModel bound to the route,
    deliberately no Mercure topic.

The "no topic" choice is the design call worth documenting: read-models
are queries, not reactive resources, and pretending otherwise would
either auto-publish stale aggregates on every entity change or require
the user to invent invalidation logic in the listener. Better: pair
the read-model with `make:bridge:event` and call refresh() from the
QML event-handler when the underlying data really changes.

Naming convention: kebab-PLURAL routes (`/api/todo-summaries`) for
consistency with REST list semantics; resource path stays singular
under `src/ReadModel/`.

Wired into services.yaml's when@dev block. Three new snapshot
baselines (TodoSummaryReadModel.php / TodoSummaryController.php /
TodoSummaryList.qml) plus runner extension. All 14 maker outputs
verify on the committed state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:28:50 +02:00
00a64c5871 v0.2.0 (7/N): make:bridge:event maker
Implements PLAN.md §8's third makers-table row. Single-command path
from a PHP domain event to a QML signal-handler:

  - src/Event/<Name>Event.php — readonly value object stub
  - src/EventSubscriber/<Name>Subscriber.php — listens to the event,
    republishes via PublisherInterface on app://event/<kebab-name>
    with op:"event"
  - {qml_path}/<Name>EventHandler.qml — MercureClient bound to the
    topic, re-emits the envelope's data as a typed signal

Stub uses an `array $payload` field so the user can substitute typed
properties for whatever shape they need. Subscriber example uses the
PublisherInterface contract from chunk 1; QML stub uses MercureClient
+ BackendConnection both already shipping.

Wired into services.yaml's when@dev block (autoconfigure picks up
maker.command tag, same pattern as existing BridgeResourceMaker /
BridgeWindowMaker). Three new snapshot baselines plus a snapshot
runner extension exercising the new maker against the same Todo /
TodoCompleted naming the existing baselines use.

End-to-end verified locally: maker output matches baselines, dev
container compiles, listing make:bridge:* shows the new command.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:25:26 +02:00
91f4d619fc v0.2.0 (6/N): docs/native-dialogs.md — boundary doc + Qt.labs.platform examples
PLAN.md §12 noted "Native dialogs (file pickers, notifications) — where
do they live?" as an open question with the bias "QML side via Qt".
That bias was never written up; without the doc, a Symfony developer
new to Qt would reasonably reach for "POST /api/show-dialog" or roll
a custom QML "FileDialog" using Window + ListView. Both are wrong.

The doc:
- States the boundary plainly (native UI = QML side, never PHP) plus
  the architectural reason (PHP's process can't reach the user's
  window manager; Qt's can and already wraps every platform's
  native API).
- Walks through Qt.labs.platform.FileDialog / MessageDialog /
  SystemTrayIcon / StandardPaths with copy-pasteable examples so
  apps don't need to discover Qt.labs the hard way.
- Explains the trigger-vs-effect split: user-initiated confirmations
  open from the QML handler that fired the action; server-side
  events route through Mercure and let QML decide how to surface
  them (toast / dialog / tray notification).

Anti-pattern callouts: don't dispatch dialogs from Doctrine listeners,
don't add HTTP endpoints whose only job is to trigger UI side-effects,
don't roll a custom QML file browser.

Notifications caveat: Qt.labs.platform.SystemTrayIcon::showMessage
covers the common case but routes through the tray. Richer
notifications (action buttons, replies) need platform-specific code
and are deferred — flagged in-doc.

PLAN.md §13 also mentioned "ship a small Q_INVOKABLE helper for the
common cases". Skipped: every common case Qt.labs.platform already
covers, and a wrapper would just shadow upstream's API. If a future
need surfaces a real gap (XDG portal notifications without tray,
say), that's the time to add framework-side code; the doc will
point at it.

No code changes; doc-only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:21:23 +02:00
a589b1c30d plan: move multi-arch + composer create-project from v0.3.0 to v0.9.0
Both items naturally cluster with the v0.9.0 cross-platform packaging
milestone:

- Multi-arch builds share runner + cert prerequisites with the macOS /
  Windows ports already at v0.9.0; doing them as one operational push
  is cheaper than fanning out across minors.
- Composer create-project is the "how does a new user get the
  framework" PHP-side channel — settling it alongside the OS-installer
  paths means all the entry points stabilise together for v1.0.0.

v0.3.0 keeps i18n + persistent log files (both standalone work that
doesn't need v0.9.0's operational lift).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:17:13 +02:00
f2d931e0a5 v0.2.0 (5/N): close audit sweep — BridgeOp contract test + PLAN.md status
The audit's substantive items shipped in chunks 1–4. Two remaining
loose ends inspected and parked:

- Generated controller findOr404 boilerplate. MapEntity changes the
  404 response shape away from problem+json unless framework-level
  RFC 7807 error config is updated; a private helper is net-zero
  on lines. Parking until either (a) skeleton-level RFC 7807 error
  wiring, or (b) --with-dto flipping to default-on and the legacy
  template's polish becoming irrelevant.
- ModelPublisher::extractId reflection branch. Looks dead because
  every maker-output entity has getId(), but it remains a safety net
  for hand-written entities that don't. Keeping.

This commit ships:

- BridgeOpTest — locks the enum case values against accidental
  rename. Every case value is a documented wire-format token QML
  clients hardcode, so renaming a `value` is a wire-protocol break
  and this fails the build before it ships.

- PLAN.md §13 v0.2.0 status block with what's shipped on dev
  (interfaces / BridgeOp / BridgeBundleInfo / Maker DRY / --with-dto)
  and what's still open (findOr404 polish, --with-dto default flip).

Test count 23 → 24, all passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:15:16 +02:00
5498c3c91e v0.2.0 (4/N): make:bridge:resource --with-dto + symfony/validator
Closes the input-validation gap that was the audit's headline finding.
The legacy generated controller's `if (isset($data['title']))…` body
accepted any JSON: empty title slipped through, malformed JSON got
swallowed by `?? []`, wrong types were silently coerced via casts.

The --with-dto flag generates:
  - src/Dto/Create<Name>Dto.php — readonly DTO with #[Assert\NotBlank]
    on title and #[Assert\Length(max: 255)]
  - src/Dto/Update<Name>Dto.php — same DTO with all fields nullable
    so PATCH callers send only what changed
  - src/Controller/<Name>Controller.php — same shape as the legacy
    controller but actions dispatch via #[MapRequestPayload]

Validation failures (missing required field, wrong type, malformed
JSON, oversize string) become RFC 7807 application/problem+json
automatically — Symfony's RequestPayloadValueResolver does the work.
No `if-isset` boilerplate, no silent coercion.

Behaviour:
  - --with-dto is opt-in; legacy template still ships unchanged
  - audit suggests flipping to default-on once stable; that's a
    follow-up
  - maker fails loud (composer require hint) if symfony/validator
    isn't autoloadable
  - skeleton + example/todo composer.json pull symfony/validator so
    scaffolded apps work out of the box

Snapshot test exercises both modes (legacy + --with-dto). New
baselines TodoControllerWithDto.php / CreateTodoDto.php /
UpdateTodoDto.php under tests/snapshot/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:10:52 +02:00
0710d81783 v0.2.0 (3/N): extract Maker shared helpers (NameInput, Naming)
DRY pass identified by the post-v0.1.2 audit: every make:bridge:*
maker re-implemented the same "prompt, trim, ucfirst, reject empty"
closure in interact(), and the camel-case-to-separator regex was
duplicated between BridgeResourceMaker (`_`-joined route plurals)
and BridgeCommandMaker (`-`-joined kebab slugs).

Two helpers under PhpQml\Bridge\Maker\Support:
  - NameInput::askOrFail() — replaces 3× inline closures
  - Naming::camelTo($name, $separator) — replaces 2× inline regexes

All 3 makers now go through the helpers; behaviour preserved
(maker snapshot test still passes — generated Todo / TodoController
/ TodoList / MarkAllDoneController / TodoWindow byte-identical to
the v0.1.2 baselines).

NamingTest covers the documented cases plus a regression case for
acronyms (HTTPClient → h-t-t-p-client; the regex splits at every
internal capital, which is correct for the route-slug use case).

Test count 17 → 23, all passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:02:03 +02:00
0cca0785c0 v0.2.0 (2/N): HealthController deep-load canary → BridgeBundleInfo VO
Decouples /healthz from the publisher contract. v0.1.1 wired
HealthController to constructor-inject Publisher purely as a "is the
bundle resolvable" probe — that worked but cemented the publisher's
API as a readiness-test dependency, which was awkward once
PublisherInterface landed in v0.2.0 chunk 1.

Replace with BridgeBundleInfo: a tiny readonly VO carrying the
bundle's name + class FQCN. HealthController depends on this instead.
Same deep-load semantics (broken bundle → can't construct the VO →
500 on /healthz), no leaky publisher dep.

/healthz response shape:
  - `bundle`: was `PhpQml\\Bridge\\Publisher`,
              now `PhpQml\\Bridge\\BridgeBundle`
  - `name`:   new field, reports `php-qml/bridge`

bundled-supervisor.sh's grep updated to match the new canary value
plus an assertion that the new `name` field is present (catches a
botched BridgeBundleInfo wire-up that the bundle-class-name assertion
alone would miss).

Quality + maker snapshot + bundled-supervisor integration test all
pass locally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 19:57:52 +02:00
56e3d671d9 v0.2.0 (1/N): public API surface — interfaces + BridgeOp enum
Establishes the contract layer the rest of v0.2.0 builds on. Pre-1.0
SemVer break: ModelPublisher::publishEntityChange() now takes BridgeOp
instead of a raw string.

Interfaces (Symfony idiom: same namespace as concrete, like HubInterface
next to Hub):
  - PublisherInterface — publish(string, array, bool)
  - ModelPublisherInterface — publishEntityChange(object, BridgeOp)
  - CorrelationContextInterface — set/get/clear

App code should typehint these instead of the concretes so swappable
implementations (offline-buffer publisher, multi-hub fan-out, request-
stamp correlation) remain non-breaking. Concrete classes implement them
unchanged; autowire continues to inject the implementations transparently.

BridgeOp: PHP 8.1 string-backed enum with cases Upsert / Delete /
Replace / Event matching PLAN.md §4's envelope `op` wire format.
Internal call sites updated; tests use the cases directly.

Switched typehints:
  - ModelPublisher ctor: PublisherInterface + CorrelationContextInterface
  - DoctrineBridgeListener ctor: ModelPublisherInterface
  - HealthController ctor: PublisherInterface (still emits `Publisher`
    as bundle canary value — `::class` resolves to the concrete class
    name regardless of typehint, so bundled-supervisor.sh's grep stays
    green)
  - skeleton PingController ctor: PublisherInterface (canonical app
    pattern — example/todo has no Publisher consumer to update)

Drive-by: removed deprecated setAccessible(true) call in
ModelPublisher::extractId — PHP 8.1+ allows reflection without it.

PHPStan + cs-fixer + PHPUnit (17/17) + maker snapshot all pass; dev
container compiles in the example app.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 19:50:01 +02:00
80 changed files with 3267 additions and 218 deletions

View File

@@ -77,6 +77,15 @@ jobs:
working-directory: framework/skeleton
run: cmake --build build/qml --target all_qmllint
- name: QML unit tests (qmltestrunner via Qt::QuickTest)
working-directory: framework/qml
env:
QT_QPA_PLATFORM: offscreen
run: |
cmake -S . -B build-tests -DBUILD_TESTING=ON
cmake --build build-tests --target qml_unit_tests --parallel
ctest --test-dir build-tests --output-on-failure -R qml_unit_tests
- name: Install FrankenPHP
run: |
curl -fsSL -o /usr/local/bin/frankenphp \

2
.gitignore vendored
View File

@@ -1,6 +1,8 @@
# Build artefacts
build/
**/build/
build-tests/
**/build-tests/
# Composer
vendor/

View File

@@ -6,9 +6,49 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
## [Unreleased]
### Fixed
- **`ReactiveListModel` / `ReactiveObject`: defer the initial fetch to `componentComplete()`.** Both classes now implement `QQmlParserStatus` and only fire the first `refresh()` from `componentComplete()` instead of inline from `setBaseUrl()` / `setSource()`. The pre-fix behaviour fired the GET as soon as the second of {`baseUrl`, `source`} was set — and because QML evaluates literal property assignments before bindings to other objects' properties, a model declared with literal `source` + bindings to `BackendConnection.url` / `BackendConnection.token` could fire its GET *before* the `token` binding had landed. The unauthenticated request hit Symfony's `SessionAuthenticator`, returned 401, and the model parked at `ready === false` with an empty list. Mercure subscribed anonymously (the model explicitly sets the SSE client's bearer to `""`), so subsequent server-side mutations propagated fine — masking the initial-fetch failure as "list is empty until something changes". Most visible when opening a second window via `make:bridge:window` after the first window's bindings had populated `BackendConnection`. After componentComplete, individual setter changes still trigger refresh inline as before, so token rotation / URL changes after first load behave unchanged. Regression test under [`framework/qml/tests/tst_reactive_list_model.qml`](framework/qml/tests/tst_reactive_list_model.qml) using the v0.2.0 `qmltestrunner` harness; added a `TestHttpServer` helper in the test scope that mimics `SessionAuthenticator`'s 401-on-no-bearer behaviour so the regression is observable as `ready === false` + empty `lastAuthHeader`.
## [0.2.0] — 2026-05-03
First minor release. Pre-1.0 SemVer permits API breaks; the only one is `ModelPublisher::publishEntityChange()`'s `string $op``BridgeOp $op` signature change. Apps that only consumed the framework via the makers, the Doctrine listener, and the QML module are unaffected.
The release closes the post-v0.1.2 architecture audit (interfaces, typed enum, `BridgeBundleInfo`, maker DRY, DTO-shaped controller scaffold) and delivers the §12 *Operations* row from PLAN.md (port negotiation, pre-migration auto-backup, `bridge:export`, periodic auto-update check, native-dialogs boundary doc) plus two new makers (`make:bridge:event`, `make:bridge:read-model`) and `qmltestrunner`-based QML unit tests in CI.
### Added
- (none yet — next changes land here)
- **`PublisherInterface`, `ModelPublisherInterface`, `CorrelationContextInterface`.** The bridge's three public services now ship as interfaces (same namespace as concrete, mirroring upstream `HubInterface`/`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. Existing `Publisher` / `ModelPublisher` / `CorrelationContext` classes implement the new interfaces unchanged.
- **`BridgeOp` enum.** PHP 8.1 string-backed enum (`Upsert` / `Delete` / `Replace` / `Event`) replacing the raw `'upsert'`/`'delete'` strings previously passed between `DoctrineBridgeListener` and `ModelPublisher::publishEntityChange`. Values match PLAN.md §4's envelope `op` wire format. Typo'd ops are now caught at the type level instead of silently producing envelopes clients ignore.
- **`BridgeBundleInfo` value object** carrying the bundle's name + class FQCN. `HealthController` now constructor-injects this instead of `PublisherInterface` as the deep-load canary, so the readiness probe is no longer coupled to the publisher's contract. `/healthz` response gains a `name` field (`php-qml/bridge`); the `bundle` field now reports `PhpQml\Bridge\BridgeBundle` (was `PhpQml\Bridge\Publisher`).
- **`Maker\Support\NameInput`** — shared interactive name prompt. All three `make: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 inline `preg_replace('/(?<!^)[A-Z]/', $sep.'$0', $name)` regex copies (BridgeResourceMaker emits `_`-joined route plurals, BridgeCommandMaker emits `-`-joined kebab slugs).
- **`make:bridge:resource --with-dto` opt-in.** Generates `Create<Name>Dto` + `Update<Name>Dto` under `src/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 7807 `application/problem+json` automatically (Symfony's `RequestPayloadValueResolver`) — no more `if (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-dto` the legacy template still ships unchanged. Maker fails loud if `symfony/validator` isn't autoloadable. Skeleton + example/todo composer.json pull `symfony/validator` so 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 via `PublisherInterface` on `app://event/<kebab-name>`, and a QML stub (`{qml_path}/<Name>EventHandler.qml`) that listens via `MercureClient` and re-emits as a typed `signal`. Closes the third row of PLAN.md §8's makers table; pairs with the existing `make:bridge:resource` / `command` / `window` makers 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 injecting `EntityManagerInterface`), `src/Controller/<Name>Controller.php` (single GET handler at `/api/<kebab-plural>`), and `{qml_path}/<Name>List.qml` (`ReactiveListModel` bound to the route, deliberately *no* Mercure topic — read-models aren't auto-reactive; invalidation is event-driven via `make: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 to `var/data.sqlite.<unix-timestamp>.bak` before invoking `doctrine: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 their `var/data.sqlite` lifecycle. Bundled-supervisor integration test gained an assertion that a `.bak` file appears under the user data dir on second launch.
- **`bridge:export` console command + QML hook.** New `bin/console bridge:export <destination>` copies the active SQLite database to a user-chosen path (overwrites if the destination exists; reads the source path from `DATABASE_URL` so it works in both dev and bundled mode). Mirrored on the QML side as `BackendConnection.exportDatabase(path)` (`Q_INVOKABLE bool`) returning success synchronously and emitting `databaseExported(path)` / `databaseExportFailed(reason)` for async UX. QML callers typically pair it with `Qt.labs.platform.FileDialog` (see `docs/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 `AppImageUpdate` poll on the first `Online` transition: 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 existing `checkForUpdates()` Q_INVOKABLE remains the install trigger, this just automates the polling. Disable with `BRIDGE_AUTO_UPDATE_DISABLE=1`; override the period with `BRIDGE_AUTO_UPDATE_PERIOD_MIN=<minutes>`. Dev mode skips entirely.
- **Bundled-mode port negotiation.** The hardcoded `m_port = 8765` is replaced with a runtime-negotiated free ephemeral port: bind a `QTcpServer` to `QHostAddress::LocalHost` port 0, capture `serverPort()`, close, then hand the port to FrankenPHP via the existing `PORT` env 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 via `BRIDGE_PORT=<n>` for reproducibility (the existing `bundled-supervisor.sh` and `perfsmoke.sh` both export it). Each launch also writes the chosen port to `var/bridge.port` so any external tool that needs the runtime address can read it without parsing Qt's log.
- **`qmltestrunner` QML 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=ON` so production AppImages don't carry it. One smoke test (`tst_smoke.qml`) proves the harness; future per-feature tests land beside it as `tst_<feature>.qml`. Wired into `make qmltest` (skeleton + example/todo) and into the Gitea Actions `Quality` job 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.** `ModelPublisher` constructor takes `PublisherInterface` + `CorrelationContextInterface`; `DoctrineBridgeListener` takes `ModelPublisherInterface`; `HealthController` takes `BridgeBundleInfo`; the skeleton's `PingController` takes `PublisherInterface`. Autowire continues to inject the concrete implementations transparently.
- **`/healthz` response shape.** `bundle` field's value changes from `PhpQml\Bridge\Publisher` to `PhpQml\Bridge\BridgeBundle`; new `name` field reports the Composer package name. JSON consumers ignoring unknown keys are unaffected; consumers asserting the `bundle` value need to migrate.
### Fixed
- **`ModelPublisher::extractId` reflection cleanup.** Removed the `setAccessible(true)` call (deprecated since PHP 8.1; all properties are accessible via Reflection without it).
### Tests
- **`BridgeOpTest` wire-format contract.** Locks the four enum case values (`upsert` / `delete` / `replace` / `event`) against accidental rename — QML clients hardcode the strings, so a `value` change 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 via `Qt.labs.platform`, not in PHP. Includes copy-pasteable examples of `FileDialog`, `MessageDialog`, `SystemTrayIcon`, and the trigger-vs-effect split for server-pushed-event-driven dialogs.
## [0.1.2] — 2026-05-03
@@ -73,7 +113,8 @@ First public preview. Phases 0 through 4a in PLAN.md are complete plus the Phase
- 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.GPL` at 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.
[Unreleased]: https://src.bundespruefstelle.ch/magdev/php-qml/compare/v0.1.2...HEAD
[Unreleased]: https://src.bundespruefstelle.ch/magdev/php-qml/compare/v0.2.0...HEAD
[0.2.0]: https://src.bundespruefstelle.ch/magdev/php-qml/releases/tag/v0.2.0
[0.1.2]: https://src.bundespruefstelle.ch/magdev/php-qml/releases/tag/v0.1.2
[0.1.1]: https://src.bundespruefstelle.ch/magdev/php-qml/releases/tag/v0.1.1
[0.1.0]: https://src.bundespruefstelle.ch/magdev/php-qml/releases/tag/v0.1.0

45
PLAN.md
View File

@@ -1,6 +1,6 @@
# php-qml — Plan for a Symfony/FrankenPHP + Qt/QML Desktop Framework
> **Status (2026-05):** v0.1.0 + v0.1.1 + v0.1.2 shipped 2026-05-03 (LGPL-3.0-or-later). Planning is version-based — see §13.
> **Status (2026-05):** v0.1.0 + v0.1.1 + v0.1.2 + v0.2.0 shipped 2026-05-03 (LGPL-3.0-or-later). Planning is version-based — see §13.
>
> **Where else to look:**
>
@@ -550,41 +550,23 @@ Bugfix release. Bundles the v0.1.1 follow-up that surfaced during the cycle (cle
- **`SessionAuthenticator` problem+json on entry-point path.** `onAuthenticationFailure` already returned RFC 7807 `application/problem+json` for *bad-token* requests, but Symfony's default entry point fired for *no-token* requests — yielding a Form-flavoured 302/401 instead. Implemented `AuthenticationEntryPointInterface::start`, factored the response into a `problemJson()` helper, so QML's RestClient sees one shape regardless of which path the firewall takes. Added test coverage.
- **`CorrelationKeyListener::onTerminate` sub-request guard.** `onRequest` already had `isMainRequest()`, but `onTerminate` cleared unconditionally — so a sub-request finishing mid-controller would wipe the main request's correlation key, causing the matching Mercure echo to lose its `correlationKey` field and the optimistic UI to never reconcile. Defensive: real-world impact is low (FrankenPHP worker mode does not currently emit sub-requests), but cheap to fix and the obvious correctness bug.
### v0.2.0 — next minor
### v0.2.0 — shipped 2026-05-03
Pulls in the originally-Phase-3/5-deferred items that don't need new operational dependencies, plus the smaller §12 risks **and** the public-API / DX items surfaced by the post-v0.1.2 audit. Cross-platform packaging is parked at v0.9.0 — the operational lift (self-hosted runners + platform certs) is too big to fold into the next minor and Linux remains the primary target until the framework's surface stabilises.
First minor release. Closes the post-v0.1.2 architecture audit, the §12 *Operations* row from PLAN.md (port negotiation, pre-migration auto-backup, `bridge:export`, periodic auto-update check, native-dialogs boundary doc), two new makers (`make:bridge:event`, `make:bridge:read-model`), and `qmltestrunner`-based QML unit tests in CI. Pre-1.0 SemVer permits API breaks; the only one is `ModelPublisher::publishEntityChange()`'s `string $op` → `BridgeOp $op` signature change. Full entry in `CHANGELOG.md`.
**Public-API surface (audit-driven, breaks pre-1.0 SemVer permitted):**
**Public-API surface (audit-driven):** `PublisherInterface` / `ModelPublisherInterface` / `CorrelationContextInterface` — internal typehints switched over, autowire continues to inject the implementations. `BridgeOp` string-backed enum replacing the raw op strings. `BridgeBundleInfo` VO replacing the publisher canary in `HealthController` (`/healthz` `bundle` field reports the bundle FQCN, new `name` field reports `php-qml/bridge`).
- **Ship interfaces for the bridge's three public services.** `Publisher`, `ModelPublisher`, and `CorrelationContext` are typehinted concretely everywhere (the Doctrine listener, the example `PingController`, every user controller that wants to fire a manual envelope) — the matching upstream Symfony idiom is `HubInterface` / `EventDispatcherInterface` / `NormalizerInterface`. Extract `PublisherInterface`, `ModelPublisherInterface`, `CorrelationContextInterface`; have the concrete classes implement them; switch every internal typehint over; document the interfaces as the public contract. Lets app code mock at the seam without a concrete-class spy and lets us iterate the implementations behind the contract.
- **`BridgeOp` enum.** `'upsert'` / `'delete'` are passed as raw strings between `DoctrineBridgeListener` and `ModelPublisher::publishEntityChange`. PHP 8.1 backed enum is the obvious typed replacement; PLAN.md §4's envelope `op` field already enumerates `upsert` | `delete` | `replace` | `event` so the enum encodes a documented contract. Method signature change is API-visible — pre-1.0 SemVer permits it; ship deprecation paths if the audit surfaces external callers.
- **`HealthController` deep-load canary refactor.** Constructor-injects `Publisher` only as a "is the bundle resolvable" probe (added in v0.1.1). Switching the dependency to a tiny `BridgeBundleInfo` value object that the bundle registers documents intent and decouples `/healthz` from the publisher contract — important once `PublisherInterface` lands.
**Maker + DX:** `Maker\Support\NameInput` + `Naming` helpers collapse the duplicated prompt closure + camel-conversion regex. `make:bridge:resource --with-dto` opt-in generates `Create<Name>Dto` + `Update<Name>Dto` and dispatches via `#[MapRequestPayload]`, closing the input-validation gap. New `make:bridge:event` and `make:bridge:read-model` makers cover the third + fourth rows of §8's makers table.
**Maker DRY + DX (audit-driven):**
**Operations:** Bundled-mode port negotiation via `QTcpServer` port-0 trick (`var/bridge.port` sentinel for tests). Pre-migration `var/data.sqlite` auto-backup (5 most recent). `bridge:export` command + `BackendConnection.exportDatabase()` Q_INVOKABLE. Periodic auto-update check (10 s after first Online + every 6 h; `BRIDGE_AUTO_UPDATE_DISABLE=1` to opt out).
- **Maker shared helpers.** All three makers re-implement the same name-prompt-or-fail closure (`ucfirst(trim(…))` plus throw on empty) and re-spell their own camel-to-snake / camel-to-kebab regexes inline. Extract `Maker\Support\NameInput::askOrFail()` and `Maker\Support\Naming::camelTo($name, '_'|'-')` — single source of truth, three call sites.
- **DTO-shaped controller scaffold (`make:bridge:resource --with-dto`).** Generated CRUD controllers currently accept any JSON shape: `if (isset($data['title'])) …` with silent type coercion, no required-field enforcement, malformed JSON swallowed as `?? []`. Add a `--with-dto` option that emits `Create<Name>Dto` + `Update<Name>Dto` DTOs alongside the controller and rewrites the action signatures to `#[MapRequestPayload] CreateTodoDto $dto`. Pulls `symfony/validator` into the skeleton/example dependencies; `#[Assert\NotBlank]` on title fields is the headline default. Symfony's payload-mapping infrastructure produces RFC 7807 problem+json on validation failure for free, fixing the field-mapping repetition between `create()` and `update()` at the same time. Once stable, flip `--with-dto` to default-on.
- **Generated controller `findOr404` boilerplate.** `update()` and `delete()` both inline the find-or-404 problem+json response. Either factor a private helper into the template or migrate to Symfony's `#[MapEntity]` attribute (ships in 7.x).
**Docs + testing:** `docs/native-dialogs.md` (Qt.labs.platform boundary). `qmltestrunner` harness under `framework/qml/tests/` wired into CTest, `make qmltest`, and the Gitea Actions Quality job.
**Makers + reactive types (Phase 3.x deferred):**
**Still open** (carried into later minors):
- **`make:bridge:event` maker.** Generate an event class + listener stub for app-side domain events.
- **`make:bridge:read-model` maker.** Generate a read-only projection (one or more entities → one denormalised view).
- **`ReactiveObject` cursor pagination.** Bring single-entity model up to par with `ReactiveListModel`'s pagination.
**Testing (Phase 3/5 deferred + §12 testing-strategy row):**
- **`qmltestrunner`-driven QML unit tests.** Wires into the `quality` job alongside qmllint.
- **End-to-end UI test (Squish or Qt Test).** Was §12's deferral; bridge-integration covers IPC, this would catch UI-only regressions.
**Operations (§12):**
- **Bundled-mode port negotiation.** `BackendConnection::m_port` is hardcoded to 8765 with no env override or negotiation, so two php-qml apps installed on the same machine collide on first launch (whichever loses the race goes Offline). Fix: bind a transient `QTcpServer` to `QHostAddress::LocalHost` port 0, grab `serverPort()`, hand it to FrankenPHP via the `PORT` env var. Needs a port-discovery mechanism for tests/perfsmoke that currently hardcode 8765 — likely write the chosen port to a sentinel file under the user data dir on supervisor activation. Surfaced from a v0.1.1 follow-up question; deferred to v0.2.0 because the test/consumer migration is wider than v0.1.x scope.
- **Pre-migration auto-backup** (§12, *Migrations on schema change*). Supervisor copies `var/data.sqlite` to `var/data.sqlite.<timestamp>.bak` before invoking `doctrine:migrations:migrate`; trims to N most recent.
- **`bridge:export` console command + UI hook** (§12, *Data backup / export*). Lets users copy their data out before machine moves or risky migrations.
- **Periodic auto-update check.** Phase 5 noted this as a polish item but didn't ship; v0.1.0 only has menu-triggered manual checks.
- **Build-time Symfony cache warmup** (§12, *Cold start*). Bake `var/cache/prod` into the AppImage so first launch skips warmup; first-launch supervisor copies it into the user data dir.
- **Native dialogs boundary doc.** §12 noted file pickers / notifications belong on the QML side via Qt — document the boundary and ship a small `Q_INVOKABLE` helper for the common cases.
- **Generated controller `findOr404` boilerplate.** Update + delete still inline the find-or-404 problem+json response. Audit suggested factoring a private helper or migrating to Symfony's `#[MapEntity]` attribute. MapEntity changes the 404 response shape away from problem+json unless framework-level RFC 7807 error config is updated; a private helper is net-zero on lines. Parking until either (a) we bake skeleton-level RFC 7807 error wiring, or (b) we flip `--with-dto` to default-on and the legacy template's polish becomes irrelevant.
- **Flip `--with-dto` to default-on.** Once surface-feedback validates the DTO templates, make it the default and gate `--no-dto` for users who want the legacy shape.
- **End-to-end UI test (Squish or Qt Test).** Was §12's deferral; bridge-integration covers IPC, this would catch UI-only regressions. Moved to a later minor because the framework / runner choice is its own decision.
### v0.3.0 — later minor
@@ -592,8 +574,7 @@ Bigger pieces still deferred (each warrants its own minor, not v0.2.0 noise):
- **i18n bridge** (§12). Symfony Translator (XLIFF) + Qt Translator (.ts) with a shared locale switch fanning out to both.
- **Persistent log files + rotation** (Phase 5 out-of-scope). Symfony monolog wiring + a Qt-host log file with rotation. The dev console stays for live tails.
- **Multi-arch builds** (§12). Linux ARM64, Windows ARM, macOS universal (arm64 + x86_64). Each adds a CI matrix dimension.
- **Composer `create-project` package** (Phase 5 out-of-scope). Publish `php-qml/skeleton` as a composer template so `composer create-project php-qml/skeleton my-app` works. Bash `bin/php-qml-init` stays for curl-bootstrap.
- **Build-time Symfony cache warmup** (§12, *Cold start*). Originally proposed for v0.2.0 but postponed: the obvious `cache:warmup` at build time + copy to user data dir at first launch doesn't actually save any time, because Symfony's compiled container bakes the absolute `kernel.project_dir` path into the cache, and the AppImage's FUSE mount path changes every launch — every cache from a prior mount is stale. Doing this properly requires virtualising `kernel.project_dir` (override `Kernel::getProjectDir()` to return a stable per-app path, symlink that path at the supervisor to the current mount, warm against the same path at build time). That's invasive enough — touches resource resolution, multi-app namespacing, the supervisor's first-launch dance — to belong in its own minor where the cache-portability story can be designed end-to-end. The v0.1.1 wipe-cache-on-every-launch behaviour stays as the correct conservative default until then.
### v0.9.0 — cross-platform packaging (release-candidate milestone)
@@ -601,8 +582,10 @@ Locks down the cross-platform story before promoting to v1.0.0. Held until v0.9.
- **macOS packaging** (was Phase 4b). `.app` bundle + `.dmg` + Sparkle 2 + notarization. Prerequisites: self-hosted macOS runner, Apple Developer cert ($99/yr), notarisation toolchain.
- **Windows packaging** (was Phase 4c). NSIS installer + WinSparkle + Authenticode signing. Prerequisites: self-hosted Windows runner, code-signing cert (EV preferred to dodge SmartScreen reputation warm-up).
- **Multi-arch builds** (§12). Linux ARM64, Windows ARM, macOS universal (arm64 + x86_64). Each adds a CI matrix dimension. Lands here rather than v0.3.0 because the runner / cert prerequisites overlap with the macOS / Windows ports above — fan-out is one operational push, not three.
- **Flathub / Snap packaging** (§12). Alternate Linux channels for better discoverability than AppImage. Each adds its own packaging surface (Flatpak manifest + Flathub PR review; snapcraft.yaml + Snap Store listing).
- **Per-platform first-launch UX** (§11, *Distribution UX*). Gatekeeper / SmartScreen / AV-vendor pre-submissions, file-association docs, App Store path decisions.
- **Composer `create-project` package** (Phase 5 out-of-scope). Publish `php-qml/skeleton` as a composer template so `composer create-project php-qml/skeleton my-app` works. Bash `bin/php-qml-init` stays for curl-bootstrap. Lands at v0.9.0 alongside the OS-installer paths so all the "how does a new user get the framework" channels stabilise together.
- **Telemetry + crash reporting** (§12). Opt-in only, off by default; PHP-side Sentry for backend errors + a per-platform crash-dump pipeline for the Qt host (each OS does this differently — fits the cross-platform-packaging milestone). Plumbing settled before v1.0.0 even if no default endpoint ships.
### v1.0.0 — when

View File

@@ -2,7 +2,16 @@
A framework for native desktop applications with a **Symfony / FrankenPHP** backend and a **Qt / QML** frontend, packaged as a single distributable per OS.
> **Status:** Phase 5 / pre-v0.1.0. Phases 04a are merged (working framework, real POC, Linux AppImage, auto-update, release CI). macOS and Windows packaging are deferred to 4b/4c. See [CHANGELOG.md](CHANGELOG.md).
[![Release](https://img.shields.io/badge/release-v0.2.0-blue)](https://src.bundespruefstelle.ch/magdev/php-qml/releases/tag/v0.2.0)
[![License](https://img.shields.io/badge/license-LGPL--3.0--or--later-blue)](LICENSE)
[![PHP](https://img.shields.io/badge/PHP-8.4%2B-777bb4)](https://www.php.net/)
[![Symfony](https://img.shields.io/badge/Symfony-8-000000)](https://symfony.com/)
[![Qt](https://img.shields.io/badge/Qt-6.5%2B-41cd52)](https://www.qt.io/)
[![FrankenPHP](https://img.shields.io/badge/FrankenPHP-1.12%2B-ff7e1d)](https://frankenphp.dev/)
[![CI](https://src.bundespruefstelle.ch/magdev/php-qml/actions/workflows/ci.yml/badge.svg?branch=main)](https://src.bundespruefstelle.ch/magdev/php-qml/actions/workflows/ci.yml)
[![Platform](https://img.shields.io/badge/platform-Linux-yellow)](docs/packaging-linux.md)
> **Status:** v0.2.0 (2026-05-03). Linux AppImage is the only packaged target through the v0.2.0 / v0.3.0 minors; macOS, Windows, Flathub and Snap all land together in [v0.9.0](PLAN.md#v090--cross-platform-packaging-release-candidate-milestone) as a single cross-platform packaging push. Pre-v1.0 SemVer permits API breaks on minor bumps — see [CHANGELOG.md](CHANGELOG.md).
---
@@ -16,6 +25,16 @@ php-qml lets a PHP developer write a desktop app using ordinary Symfony on the b
It is **not** a PHP↔Qt language binding — the languages run in separate processes; the bridge is a wire protocol, not an FFI layer. That deliberately avoids the failure mode that left php-gtk and php-qt unmaintained.
## What you get
- **One ~150 MB AppImage** bundling Qt, the Symfony app, FrankenPHP, and the AppImageUpdate sidecar. Cold start ≤ 2 s on bare metal (≤ 4 s on shared CI runners), idle RSS ≤ 200 MB. Gates enforced in CI on every release tag.
- **Five `make:bridge:*` makers** covering CRUD, non-CRUD commands, domain events, query-only read-models, and second windows. The headline `make:bridge:resource` generates entity + REST controller + `ReactiveListModel`-bound QML in one command, with optional `--with-dto` for `#[MapRequestPayload]` + RFC 7807 validation.
- **Reactive models out of the box.** `ReactiveListModel` / `ReactiveObject` do the initial GET, subscribe to Mercure, apply optimistic mutations, and reconcile via `Idempotency-Key``correlationKey` round-tripping — no handwritten cross-side glue.
- **Production-grade bundled-mode supervisor.** Per-session bearer + JWT secrets (rotated on every restart), pre-migration SQLite auto-backup, runtime-negotiated TCP port (no two installed apps collide), `prctl(PR_SET_PDEATHSIG)` so a host crash takes the child with it.
- **Self-update** via embedded `zsync` (typical delta 1020 MB). Auto-checks on launch and every 6 h; the install step is always user-driven, never auto-restart.
- **Opt-in DX**: `bridge:doctor` readiness probe, `bridge:export` database backup, `DevConsole` QML in-window log viewer (Ctrl+backtick), single-instance lock with launch-arg forwarding (file-association friendly), shipped `.vscode/` + `.idea/` configs.
- **Quality gate on every push**: PHPStan + php-cs-fixer + PHPUnit + qmllint + `qmltestrunner` + an HTTP/SSE round-trip integration test + a bundled-supervisor smoke test + `perfsmoke` against the budgets above.
## 60-second tour
```bash
@@ -34,12 +53,14 @@ Add a reactive resource (entity + REST controller + QML snippet) with one maker:
```bash
cd my-app/symfony
bin/console make:bridge:resource Todo
bin/console make:bridge:resource Todo # add --with-dto for #[MapRequestPayload] + RFC 7807 errors
bin/console make:migration && bin/console doctrine:migrations:migrate -n
```
`make dev` opens the Qt window, connection state flips to **Online**, and the generated `TodoList.qml` shows a list whose `ReactiveListModel` is auto-subscribed to `app://model/todo` over Mercure. There is no handwritten cross-side glue.
The maker family covers the four common shapes: [`make:bridge:resource`](docs/makers.md#makebridgeresource) (CRUD), [`make:bridge:command`](docs/makers.md#makebridgecommand) (non-CRUD action), [`make:bridge:event`](docs/makers.md#makebridgeevent) (domain event → QML signal), [`make:bridge:read-model`](docs/makers.md#makebridgeread-model) (query-only projection), and [`make:bridge:window`](docs/makers.md#makebridgewindow) (second window).
For a non-trivial app with a multi-window test, crash-recovery test, and AppImage packaging, see [`examples/todo/`](examples/todo/README.md).
## Documentation
@@ -50,12 +71,13 @@ The full developer documentation lives under [`docs/`](docs/README.md):
- **[Architecture](docs/architecture.md)** — process pair, transport, dev vs bundled mode.
- **[Update semantics](docs/update-semantics.md)** — connection state machine, optimistic mutations, idempotency.
- **[Reactive models](docs/reactive-models.md)** — `ReactiveListModel`, `ReactiveObject`, Mercure dual-publish.
- **[Makers](docs/makers.md)** — `make:bridge:resource` / `command` / `window`.
- **[Dev workflow](docs/dev-workflow.md)** — hot reload, dev console, editor setup, `bridge:doctor`.
- **[Bundled mode](docs/bundled-mode.md)** — supervisor, per-session secret rotation, first-launch migrations.
- **[Linux packaging](docs/packaging-linux.md)** — `make appimage`, auto-update, performance budgets.
- **[Makers](docs/makers.md)** — `make:bridge:resource` / `command` / `event` / `read-model` / `window`.
- **[Dev workflow](docs/dev-workflow.md)** — hot reload, dev console, editor setup, `bridge:doctor`, `make qmltest`.
- **[Bundled mode](docs/bundled-mode.md)** — supervisor, per-session secret rotation, port negotiation, pre-migration auto-backup, first-launch migrations.
- **[Linux packaging](docs/packaging-linux.md)** — `make appimage`, auto-update (launch + 6h poll), performance budgets.
- **[Native dialogs](docs/native-dialogs.md)** — file pickers, message boxes, system tray; the QML/PHP boundary.
- **[Configuration reference](docs/configuration.md)** — env vars, CLI flags.
- **[QML API reference](docs/qml-api.md)** / **[PHP API reference](docs/php-api.md)** — singletons, components, attributes, services.
- **[QML API reference](docs/qml-api.md)** / **[PHP API reference](docs/php-api.md)** — singletons, components, attributes, services, interfaces.
Design rationale and roadmap live in [PLAN.md](PLAN.md). User-facing changes per release are in [CHANGELOG.md](CHANGELOG.md).
@@ -65,29 +87,39 @@ PHP 8.4+ · Symfony 8 · Doctrine ORM 3 · FrankenPHP 1.12+ (worker mode) · Mer
## Roadmap
- **Phase 0** ✅ throwaway transport spike.
- **Phase 1** ✅ framework skeleton, dev mode, single-instance lock, CI quality gate.
- **Phase 2** ✅ reactive models, update semantics, headline maker.
- **Phase 3** ✅ POC todo app, integration + snapshot tests.
- **Phase 4a** ✅ bundled mode, Linux AppImage, release CI, AppImageUpdate.
- **Phase 4b/4c** ⏳ macOS / Windows packaging.
- **Phase 5** 🚧 DX polish — dev console, init script, hot-reload docs, v0.1.0 prep.
The original Phase 05 POC roadmap shipped as v0.1.0 on 2026-05-03. From there on, work is organised by SemVer version (see [PLAN.md §13](PLAN.md#13-versions) for the full per-version breakdown).
- **v0.1.0** ✅ first public preview — process-pair architecture, reactive models, three headline makers, bundled mode, Linux AppImage, AppImageUpdate, release CI, DX polish (dev console, `php-qml-init`, editor configs).
- **v0.1.1** ✅ shakedown follow-ups — `/healthz` deep-load canary, bundled-supervisor integration test, skeleton AppImage parity, cache-wipe on bundled launch.
- **v0.1.2** ✅ post-shakedown audit — clean child shutdown via `aboutToQuit`, configurable `bridge.qml_path`, `SessionAuthenticator` problem+json on the entry-point path, `CorrelationKeyListener` sub-request guard.
- **v0.2.0** ✅ public-API surface (`BridgeOp` enum, `PublisherInterface` / `ModelPublisherInterface` / `CorrelationContextInterface`, `BridgeBundleInfo`), port negotiation, pre-migration auto-backup, `bridge:export`, periodic auto-update check, `make:bridge:event` + `make:bridge:read-model` makers, `--with-dto` opt-in, `qmltestrunner` in CI.
- **v0.3.0** ⏳ later minor — i18n bridge (Symfony Translator + Qt Translator with shared locale switch), persistent log files + rotation, build-time Symfony cache warmup (requires `kernel.project_dir` virtualisation, hence its own minor).
- **v0.9.0** ⏳ cross-platform packaging release-candidate milestone — macOS (`.app` + Sparkle 2 + notarisation), Windows (NSIS + WinSparkle + Authenticode), Flathub + Snap, multi-arch (Linux ARM64, Windows ARM, macOS universal), composer `create-project php-qml/skeleton`, opt-in telemetry + crash reporting. Held until one push because the cert / runner / notarisation prerequisites overlap.
- **v1.0.0** ⏳ API stabilisation — auth model finalised, AppImage relinkability documented end-to-end, security model audited. Pre-1.0 minor bumps may still break public API.
## Tested platforms
| OS | Packaging | CI |
| --------------- | --------- | -------------------------- |
| Linux x86_64 | AppImage | Gitea Actions (every push) |
| macOS / Windows | v0.9.0 | — |
Performance gates (`tests/perfsmoke.sh`) enforced on every release tag: bundle ≤ 200 MB, cold start ≤ 2 s (4 s on shared CI), idle RSS ≤ 200 MB. See [docs/packaging-linux.md §performance smoke](docs/packaging-linux.md#performance-smoke).
## Contributing
Active development happens on the `dev` branch; `main` only carries release commits. Pull requests target `dev`.
```bash
cd framework/php && composer quality # PHPStan + cs-fixer + PHPUnit
cd examples/todo && make quality # adds qmllint + integration test
cd framework/php && composer quality # PHPStan + cs-fixer + PHPUnit
cd framework/skeleton && make qmltest # qmltestrunner unit tests (Quick Test)
cd examples/todo && make quality # adds qmllint + integration test
```
A dedicated `CONTRIBUTING.md` arrives with Phase 5's wrap-up.
## Versioning
[Semantic Versioning](https://semver.org/) — `MAJOR.MINOR.BUGFIX`. Pre-v1.0.0, minor bumps may break public API.
[Semantic Versioning](https://semver.org/) — `MAJOR.MINOR.BUGFIX`. Pre-v1.0.0, minor bumps may break public API; bugfix bumps don't.
## License
To be decided before v0.1.0 is tagged. The framework's own code will be permissively licensed; Qt is shipped under LGPL with relinkability obligations — see [PLAN.md §12](PLAN.md#12-open-questions-and-risks).
[**LGPL-3.0-or-later**](LICENSE) — chosen to align with Qt 6's LGPLv3 licensing. The bundled AppImage honours the relinkability obligations (Qt libs are shipped as separate `.so`s, not statically linked); see [PLAN.md §12](PLAN.md#12-open-questions-and-risks) for the full rationale.

View File

@@ -15,15 +15,16 @@ Developer documentation for [php-qml](../README.md). Design rationale and roadma
## Guides
- **[Makers](makers.md)** — `make:bridge:resource`, `make:bridge:command`, `make:bridge:window`.
- **[Dev workflow](dev-workflow.md)** — hot reload (PHP + QML), dev console (`Ctrl+\``), editor configs, `bridge:doctor`.
- **[Linux packaging](packaging-linux.md)** — `make appimage`, AppImageUpdate, performance budgets.
- **[Makers](makers.md)** — `make:bridge:resource` (`--with-dto` opt-in), `make:bridge:command`, `make:bridge:event`, `make:bridge:read-model`, `make:bridge:window`.
- **[Dev workflow](dev-workflow.md)** — hot reload (PHP + QML), dev console (Ctrl+backtick), editor configs, `bridge:doctor`, `make qmltest`.
- **[Linux packaging](packaging-linux.md)** — `make appimage`, AppImageUpdate (launch + periodic), performance budgets.
- **[Native dialogs](native-dialogs.md)** — file pickers, confirmations, system notifications: where they live (QML, not PHP) and how to use the platform-native components Qt already ships.
## Reference
- **[QML API](qml-api.md)** — `BackendConnection`, `RestClient`, `MercureClient`, `ReactiveListModel`, `ReactiveObject`, `AppShell`, `DevConsole`, `SingleInstance`.
- **[PHP API](php-api.md)** — `BridgeBundle`, `#[BridgeResource]`, `ModelPublisher`, `bridge:doctor`, `CorrelationKeyListener`, `SessionAuthenticator`.
- **[Configuration](configuration.md)** — env vars (`BRIDGE_URL`, `BRIDGE_TOKEN`, `FRANKENPHP`, …) and CLI flags for `php-qml-init` / `build-appimage.sh`.
- **[PHP API](php-api.md)** — `BridgeBundle`, `#[BridgeResource]`, `BridgeOp` enum, `PublisherInterface`, `ModelPublisherInterface`, `CorrelationContextInterface`, `BridgeBundleInfo`, `bridge:doctor`, `bridge:export`, `SessionAuthenticator`.
- **[Configuration](configuration.md)** — env vars (`BRIDGE_URL`, `BRIDGE_TOKEN`, `BRIDGE_PORT`, `BRIDGE_AUTO_UPDATE_*`, `FRANKENPHP`, …) and CLI flags for `php-qml-init` / `build-appimage.sh`.
## How the docs are organised

View File

@@ -19,7 +19,7 @@ A running app is two processes:
- The **Qt host** is the long-lived parent. It owns the window, input, and rendering. It also owns the lifecycle of the FrankenPHP child.
- **FrankenPHP** runs Symfony in *worker mode*: PHP boots once, the kernel stays warm, and incoming HTTP requests reuse the bootstrapped container. That's how cold-start stays under ~2 s for a non-trivial Symfony app.
- They talk over `127.0.0.1:8765` by default. Loopback only — there is no network exposure.
- They talk over loopback (`127.0.0.1`). In dev mode the port is `8765` by default; in bundled mode the host negotiates a free ephemeral port at launch (so two installed apps don't collide). Either way it's loopback only — no network exposure.
- The bridge is a **wire protocol**, not an FFI layer. Either side can be replaced (the Qt host could be a different GUI; the backend could be a different language) without changing the other.
## Transport

View File

@@ -19,6 +19,22 @@ if (!explicitUrl.isEmpty()) {
Bundled-mode init runs after the QApplication event loop is up so `QStandardPaths` and `QProcess` work correctly.
## Port negotiation
Bundled mode does not hardcode a TCP port. On every spawn the host:
1. Binds a `QTcpServer` to `QHostAddress::LocalHost` port 0 (kernel picks a free ephemeral port).
2. Captures `serverPort()`, then closes the probe socket.
3. Hands the chosen port to FrankenPHP via the `PORT` env var.
The bundled `Caddyfile` reads `{$PORT:8765}`, so it picks up whatever the host negotiated and falls back to `8765` only when the env is unset (i.e. dev mode without an override).
The chosen port is also written to `~/.local/share/<app>/var/bridge.port` on every launch. External tools (a debug helper, a `curl /healthz` from a script) can read the file instead of grepping Qt's log for the address.
For reproducible test harnesses, pin the port via the `BRIDGE_PORT=<n>` env var. Both `examples/todo/tests/bundled-supervisor.sh` and `tests/perfsmoke.sh` set this so multiple harnesses can run side by side without contending. When `BRIDGE_PORT` is set the negotiation step is skipped.
Why negotiate at all? Two installed php-qml apps used to race for `8765` on first launch — whichever lost went Offline. Negotiation eliminates the collision class; the kernel guarantees uniqueness within the host.
## Resolving the FrankenPHP child
Bundled mode needs three things on disk near the host binary:
@@ -86,6 +102,20 @@ env.insert("DATABASE_URL", databaseUrl()); // sqlite:///<userdata>/var/data.sq
If migrations fail or time out, bundled mode goes Offline before spawning. Apps that want to handle a corrupt DB gracefully can detect this via `BackendConnection.error` and show a "reset database?" UI.
### Pre-migration auto-backup
Before invoking `doctrine:migrations:migrate`, the supervisor copies `var/data.sqlite` to `var/data.sqlite.<unix-timestamp>.bak` and keeps the 5 most recent backups. SQLite has no transactional DDL — a half-applied migration can corrupt the database with no rollback path. The backup is cheap insurance against that.
- Skipped on first launch (no DB exists yet).
- Failure to copy logs a warning and continues — a missing safety net is not a reason to refuse to boot.
- Bundled mode only. Dev-mode users own `symfony/var/data.sqlite` themselves.
If migration corrupts the DB, the user's recovery is `cp var/data.sqlite.<latest>.bak var/data.sqlite` from the data directory; the next launch boots against the rolled-back schema (and a future migration retry succeeds against the original state).
### Database export
Apps can offer a "Save a copy of my data" button by calling [`BackendConnection.exportDatabase(path)`](qml-api.md#exportdatabase) (Q_INVOKABLE) — typically paired with `Qt.labs.platform.FileDialog`. The same operation is available as `bin/console bridge:export <destination>` for CLI use. Both read the source path from `DATABASE_URL` so they work in dev mode and bundled mode unchanged.
## Supervisor
The supervisor is `BackendConnection::onChildFinished()` plus a retry counter:
@@ -124,7 +154,7 @@ m_child->setChildProcessModifier([] {
});
```
Without this, a host crash leaves an orphan FrankenPHP process holding port 8765, and the *next* launch can't bind.
Without this, a host crash leaves an orphan FrankenPHP process holding the negotiated port (and consuming the user's data files); the *next* launch finds no parent to connect back to but the orphan still races for resources.
`PR_SET_PDEATHSIG` only works on Linux. macOS and Windows builds will use platform-equivalents in their respective phases (see PLAN.md §4b/§4c).
@@ -134,7 +164,7 @@ Same as dev mode: `GET /healthz` every 5 s, 2 s timeout, 30 s threshold for Offl
## Auto-update
Bundled mode also wires up the AppImageUpdate sidecar — see [Linux packaging §auto-update](packaging-linux.md#auto-update). Three QML signals carry the result:
Bundled mode wires up the AppImageUpdate sidecar — see [Linux packaging §auto-update](packaging-linux.md#auto-update). Three QML signals carry the result:
```qml
Connections {
@@ -150,6 +180,17 @@ Button { text: "Update"; onClicked: BackendConnection.applyUpdate() }
Both methods are no-ops in dev mode — they emit `updateCheckFailed("update checks are bundled-mode only")` so QML can treat them uniformly.
### Periodic check
The supervisor arms an automatic poll on the first `Online` transition: a launch-time check 10 s after the backend is ready, then a recurring check every 6 hours. PLAN.md §11 *Auto-update* asked for "check on launch and once per N hours; offer install on next restart, never auto-restart" — the poll surfaces `updatesAvailable()` so apps can show a banner; `applyUpdate()` is still the explicit install trigger and there is no auto-restart.
| Env var | Default | Effect |
| --- | --- | --- |
| `BRIDGE_AUTO_UPDATE_DISABLE` | unset | Set to `1` to disable the periodic poll. The Q_INVOKABLE `checkForUpdates()` / `applyUpdate()` still work. |
| `BRIDGE_AUTO_UPDATE_PERIOD_MIN` | `360` (6 h) | Override the period in minutes. |
Dev mode skips the periodic check entirely.
## Single-instance lock
The host uses `SingleInstance` (a QLocalServer-backed lock) regardless of mode:

View File

@@ -10,10 +10,13 @@ Exhaustive lookup for env vars and CLI flags. For *what* the framework does with
| --- | --- | --- |
| `BRIDGE_URL` | unset | If set, host runs in [dev mode](architecture.md#dev-mode-vs-bundled-mode) and connects to this URL. If unset, host runs in [bundled mode](bundled-mode.md). |
| `BRIDGE_TOKEN` | unset | Bearer token for `Authorization` headers. Dev mode reads this from env (typically set in `.env`); bundled mode generates a per-session value and ignores env. |
| `BRIDGE_PORT` | (negotiated) | Bundled mode: pin a specific TCP port instead of negotiating one. Set by test harnesses (`bundled-supervisor.sh`, `perfsmoke.sh`) for reproducibility. Dev mode ignores it (the Caddyfile still reads `{$PORT:8765}`). |
| `BRIDGE_FRANKENPHP_BIN` | `<bin>/bin/frankenphp` | Bundled mode: override the FrankenPHP binary path. |
| `BRIDGE_SYMFONY_DIR` | candidate list | Bundled mode: override the Symfony app directory. Candidates: `<bin>/symfony`, `<bin>/../symfony`, `<bin>/../share/<app>/symfony`, `<bin>/../usr/share/<app>/symfony`. |
| `BRIDGE_CADDYFILE` | candidate list | Bundled mode: override the Caddyfile path. Same candidate prefixes as `BRIDGE_SYMFONY_DIR`. |
| `BRIDGE_APPIMAGEUPDATE_BIN` | `<bin>/AppImageUpdate.AppImage` | Override the auto-update sidecar path. |
| `BRIDGE_AUTO_UPDATE_DISABLE` | unset | Bundled mode: set to `1` to disable the periodic auto-update poll. The QML `checkForUpdates()` / `applyUpdate()` Q_INVOKABLEs still work. |
| `BRIDGE_AUTO_UPDATE_PERIOD_MIN` | `360` | Bundled mode: override the periodic auto-update interval in minutes (default 6 h). |
| `APPIMAGE` | set by AppImage runtime | Bundled-mode auto-update reads this to know which AppImage to update. |
### Read by the bundled Symfony app
@@ -32,7 +35,7 @@ These come from `framework/skeleton/symfony/.env` in dev mode and from environme
| `MERCURE_JWT_SECRET` | HMAC secret for minting publisher JWTs. ≥256 bits. |
| `MERCURE_PUBLISHER_JWT_KEY` | Same value as `MERCURE_JWT_SECRET`. |
| `MERCURE_SUBSCRIBER_JWT_KEY` | Same value as `MERCURE_JWT_SECRET`. |
| `PORT` | `8765`. Used by the Caddyfile. Override for non-default ports. |
| `PORT` | `8765` in dev. In bundled mode the host sets this to the negotiated port (or to `BRIDGE_PORT` if pinned). The Caddyfile reads `{$PORT:8765}`. |
### Read by `make dev` / `scripts/dev.sh`
@@ -85,6 +88,20 @@ php-qml-init [--framework <dir>] [--vendor] [--skip-install] [--git] <name>
The script auto-validates that `<name>` doesn't already exist (or that the existing dir is empty). It rewrites every `skeleton` identifier to the project name (CMake project, Qt target, QML URI, app title, single-instance lock id, composer path-repo, CMake `add_subdirectory(framework/qml)`, `.vscode/launch.json`).
### `bin/console bridge:export`
```
bin/console bridge:export <destination>
```
Copies the active SQLite database (read from `DATABASE_URL`) to `<destination>`. Overwrites the destination if it exists. Works in both dev and bundled mode.
| Arg | Required | Notes |
| --- | --- | --- |
| `<destination>` | yes | Filesystem path. The QML side has the same operation as `BackendConnection.exportDatabase(path)`. |
Errors with exit code `1` if `DATABASE_URL` doesn't point at a SQLite file or if the source doesn't exist. See [PHP API §bridge:export](php-api.md#bridgeexport).
### `packaging/linux/build-appimage.sh`
```
@@ -114,9 +131,11 @@ Run from a project's root (skeleton, todo example, or a `php-qml-init`'d project
| `make dev` | `make build` + `scripts/dev.sh` (FrankenPHP `--watch` + Qt host). |
| `make doctor` | `bin/console bridge:doctor`. |
| `make doctor-connect` | `bin/console bridge:doctor --connect`. |
| `make quality` | PHP quality + qmllint + (todo example) integration test. |
| `make qmltest` | Configure with `-DBUILD_TESTING=ON`, run the `qmltestrunner` Quick Test target via CTest. Skeleton + `examples/todo`. |
| `make quality` | PHP quality + qmllint + `qmltest` + (todo example) integration test. |
| `make integration` | (todo example only) HTTP+SSE round-trip + crash-recover smoke. |
| `make appimage` | (todo example only) Stage symfony --no-dev, run `build-appimage.sh`. |
| `make integration-bundled` | (todo example only) bundled-mode supervisor smoke (cache redirect + auto-backup + clean shutdown). |
| `make appimage` | Stage symfony --no-dev, run `build-appimage.sh`. (Skeleton + todo example.) |
| `make perf` | (todo example only) Run `tests/perfsmoke.sh` against the built AppImage. |
| `make clean` | Remove `build/`. |
@@ -126,11 +145,13 @@ Run from a project's root (skeleton, todo example, or a `php-qml-init`'d project
| What | Where |
| --- | --- |
| FrankenPHP HTTP / Mercure | `http://127.0.0.1:8765` |
| FrankenPHP HTTP / Mercure | `http://127.0.0.1:<port>` (`8765` in dev; negotiated in bundled mode) |
| Mercure SSE endpoint | `/.well-known/mercure` |
| Health probe | `GET /healthz` (returns `200 OK` when ready) |
| Health probe | `GET /healthz` (returns `200 OK` when ready; response carries `name`, `bundle`) |
| Bundled-mode user data | `~/.local/share/<app>/var/` (Linux). `XDG_DATA_HOME` honoured. |
| Bundled-mode SQLite | `~/.local/share/<app>/var/data.sqlite` |
| Bundled-mode auto-backups | `~/.local/share/<app>/var/data.sqlite.<timestamp>.bak` (last 5 kept) |
| Bundled-mode runtime port | `~/.local/share/<app>/var/bridge.port` (written every launch) |
| Bundled-mode logs | `~/.local/share/<app>/var/log/` |
| Single-instance socket | `~/.local/share/<app>/<app>.sock` |
| AppImage AppDir layout | `usr/bin/<app>`, `usr/share/<app>/symfony/`, `usr/share/<app>/Caddyfile`, `usr/bin/AppImageUpdate.AppImage` |

View File

@@ -165,6 +165,25 @@ If you intentionally changed the template, regenerate the snapshot and commit it
git add tests/snapshot/
```
## QML unit tests (`make qmltest`)
`framework/qml/tests/` ships a [Qt Quick Test](https://doc.qt.io/qt-6/qtquicktest-index.html) executable target (`qml_unit_tests`) discovered by CTest. Built only when CMake is configured with `-DBUILD_TESTING=ON`, so production AppImages don't carry it.
Locally:
```bash
cd framework/skeleton # or examples/todo / a php-qml-init'd project
make qmltest
# → cmake -DBUILD_TESTING=ON -S qml -B build/qml
# → cmake --build build/qml --target qml_unit_tests
# → ctest --test-dir build/qml --output-on-failure
# ✓ tst_smoke.qml passed
```
Add per-feature tests next to `tst_smoke.qml` as `tst_<feature>.qml` — Quick Test auto-discovers them. Tests run under the `offscreen` Qt platform plugin so CI doesn't need `xvfb`.
This is wired into `make quality` (skeleton + todo example) and into the Gitea Actions `Quality` job after qmllint, so QML regressions fail the build alongside PHP regressions.
## Integration test loop
`examples/todo/tests/integration.sh` boots the example app in dev mode, fires a real HTTP+SSE round-trip plus a crash-recover, and asserts the output. Run it after touching anything in `BackendConnection`, `MercureClient`, or `ReactiveListModel`:

View File

@@ -233,7 +233,7 @@ 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.
- 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](bundled-mode.md#port-negotiation).)
### `composer install` fails with "your php version (8.3.x) does not satisfy"

View File

@@ -1,14 +1,16 @@
# Makers
php-qml ships three [`symfony/maker-bundle`](https://symfony.com/bundles/SymfonyMakerBundle/current/index.html) makers. They generate the cross-side wiring (entity + controller + QML) for the common shapes — that's how a non-trivial app's PHP↔QML glue stays effectively zero.
php-qml ships five [`symfony/maker-bundle`](https://symfony.com/bundles/SymfonyMakerBundle/current/index.html) makers. They generate the cross-side wiring (entity / controller / event / read-model / second window) for the common shapes — that's how a non-trivial app's PHP↔QML glue stays effectively zero.
All three are invoked from `symfony/`:
All of them are invoked from `symfony/`:
```bash
cd symfony
bin/console make:bridge:resource <Name>
bin/console make:bridge:command <Name>
bin/console make:bridge:window <Name>
bin/console make:bridge:resource <Name> # CRUD: entity + controller + ReactiveListModel
bin/console make:bridge:command <Name> # non-CRUD action endpoint
bin/console make:bridge:event <Name> # domain event → Mercure → typed QML signal
bin/console make:bridge:read-model <Name> # query-only projection (no Mercure)
bin/console make:bridge:window <Name> # second-window QML scaffold
```
The maker bundle is `require-dev`, so production AppImage builds (which use `composer install --no-dev`) intentionally skip it. Run makers in development; check the output into git.
@@ -65,6 +67,35 @@ When to use which:
- **UUIDv7 (default)** — distributed-friendly, exposes no insertion-order leak, sortable. Good for anything an end-user might sync between machines.
- **Integer ID** — easier to debug from a shell; smaller wire size; no client-side ID generation needed. Pick this for purely-local single-machine apps.
### `--with-dto` — typed payloads + RFC 7807 errors
Pass `--with-dto` to opt the controller into Symfony's `#[MapRequestPayload]` resolver:
```bash
bin/console make:bridge:resource Todo --with-dto
# created: src/Entity/Todo.php
# created: src/Dto/CreateTodoDto.php
# created: src/Dto/UpdateTodoDto.php
# created: src/Controller/TodoController.php
# created: ../qml/TodoList.qml
```
The generated controller dispatches via the DTOs:
```php
public function create(#[MapRequestPayload] CreateTodoDto $dto): JsonResponse { /* … */ }
public function update(Todo $todo, #[MapRequestPayload] UpdateTodoDto $dto): JsonResponse { /* … */ }
```
What you get for free:
- **Malformed JSON** → 400 `application/problem+json`. No `if (!is_array($data))` boilerplate.
- **Missing required fields / `#[Assert\NotBlank]` violations** → 422 `application/problem+json` with field-by-field detail. `RestClient` parses the response into the `commandFailed` rejection's `problem` arg automatically.
- **No silent type coercion** — `done: "yes"` rejects instead of being cast to true.
- **PATCH semantics** — `Update<Name>Dto` fields default to nullable so callers send only what changed.
Without `--with-dto` the controller still ships and works — the DTO opt-in is for apps that want the RFC 7807 contract end-to-end. The maker fails loud if `symfony/validator` isn't autoloadable; the skeleton + `examples/todo` already require it.
#### `src/Controller/<Name>Controller.php`
CRUD endpoints on `/api/<lowercase-name>`:
@@ -170,6 +201,70 @@ CRUD covers the 80%. Reach for a command when:
- The action affects multiple resources atomically (e.g. *mark all done* across a collection).
- You need request-side validation that doesn't fit the entity (e.g. *only the owner can do this*).
## `make:bridge:event`
Generates a domain-event class, an event subscriber that republishes via `PublisherInterface` on `app://event/<kebab-name>`, and a QML stub that re-emits the wire payload as a typed signal.
```bash
bin/console make:bridge:event ImportFinished
# created: src/Event/ImportFinishedEvent.php
# created: src/EventSubscriber/ImportFinishedSubscriber.php
# created: ../qml/ImportFinishedEventHandler.qml
```
The generated event is a readonly value object — fields are arguments to `__construct`, exposed as readonly properties. The subscriber listens for the event, normalises it to JSON, and publishes through the bundle's `PublisherInterface`. The QML stub instantiates a `MercureClient` on the topic and re-emits the parsed payload as a typed `signal`:
```qml
ImportFinishedEventHandler {
onTriggered: function(payload) {
tray.showMessage("Import finished", `${payload.rowCount} rows`)
}
}
```
Use it from your code by dispatching the event:
```php
public function __invoke(EventDispatcherInterface $dispatcher): void
{
// … import work …
$dispatcher->dispatch(new ImportFinishedEvent(rowCount: $count));
}
```
### When to reach for an event vs a `BridgeResource`
- **Resource changed** (an entity was created / updated / deleted) → `#[BridgeResource]` does the dual-publish for you.
- **Something happened that isn't a resource state change** (background job done, push notification, validation outcome) → `make:bridge:event`. The QML side gets a typed signal instead of trying to derive intent from state diffs.
The split keeps the *what changed* (resource topics) separate from the *what happened* (event topics) so QML subscribers don't have to filter.
## `make:bridge:read-model`
Generates a query-only projection: a query service, a single GET controller, and a `ReactiveListModel`-bound QML stub — deliberately *without* a Mercure topic.
```bash
bin/console make:bridge:read-model OverdueTodos
# created: src/ReadModel/OverdueTodosReadModel.php
# created: src/Controller/OverdueTodosController.php
# created: ../qml/OverdueTodosList.qml
```
| File | Purpose |
| --- | --- |
| `src/ReadModel/<Name>ReadModel.php` | Query service stub. Inject `EntityManagerInterface`; return DTOs/arrays. |
| `src/Controller/<Name>Controller.php` | `GET /api/<kebab-plural>` handler. Forwards to the read-model service. |
| `qml/<Name>List.qml` | `ReactiveListModel` bound to the route. **No `topic`** — read-models aren't auto-reactive. |
Read-models intentionally don't subscribe to a Mercure topic. They're rebuilt on demand (or on a Refresh button) and invalidated by *events*, not by raw entity persistence. To trigger a refresh from the server side, pair this maker with `make:bridge:event` — the QML stub can listen for the event signal and call `model.refresh()`.
### When to use a read-model vs a resource
- **The QML view shows the entity itself** (a row per record, fields map 1:1) → `make:bridge:resource`.
- **The QML view shows a derived projection** (joined tables, aggregates, filtered subsets, denormalised reports) → `make:bridge:read-model`. The query lives in PHP; the QML side just renders.
Read-models are the answer to "I tagged the entity with `#[BridgeResource]` but the list view needs a JOIN" — that's a different shape and shouldn't be force-fit into the dual-publish.
## `make:bridge:window`
Generates a second-window scaffold. Useful when an app is fundamentally multi-window (settings dialog, detail pop-out, second viewport).

113
docs/native-dialogs.md Normal file
View File

@@ -0,0 +1,113 @@
# Native dialogs and notifications
> **The rule:** native UI affordances — file pickers, message boxes, system notifications, tray icons — live on the **QML side**, not in PHP. PLAN.md §12 ([Native dialogs row](../PLAN.md#12-open-questions-and-risks)) treats this as a framework boundary, not an open question.
## Why this boundary
The Qt host owns the window, the input loop, and the platform integration. Anything that needs to talk to the OS's native chrome (a save-file dialog drawn by `kdialog` / `NSSavePanel` / `IFileSaveDialog`, a notification routed through `org.freedesktop.Notifications` / `NSUserNotificationCenter` / `ToastNotification`) is reachable from Qt and unreachable from PHP. PHP can produce data and decide *what* should happen; QML decides *how to surface it natively*.
Trying to dispatch a "show me a save-file dialog" from a Symfony controller means either polling the Qt side over HTTP (slow, ugly) or shipping a second Qt↔PHP transport just for UI side-effects (unnecessary). Don't.
## File pickers
Use [`Qt.labs.platform.FileDialog`](https://doc.qt.io/qt-6/qml-qt-labs-platform-filedialog.html) — it draws the platform-native dialog, not Qt's fallback rendering.
```qml
import QtQuick
import QtQuick.Controls
import Qt.labs.platform as Platform
Button {
text: "Open document…"
onClicked: openDialog.open()
Platform.FileDialog {
id: openDialog
title: "Open document"
nameFilters: ["JSON files (*.json)", "All files (*)"]
fileMode: Platform.FileDialog.OpenFile
onAccepted: console.log("user picked:", currentFile)
}
}
```
Save-file is the same component with `fileMode: Platform.FileDialog.SaveFile`. Multi-select uses `OpenFiles` and `currentFiles` (plural).
The dialog returns a `file://` URL — convert it for filesystem use with the standard QML idiom `Qt.url.toLocalFile(currentFile)` or pass the URL straight to `QFile` / `QNetworkAccessManager` if upload-as-URL fits.
## Confirmations / message boxes
Use [`Qt.labs.platform.MessageDialog`](https://doc.qt.io/qt-6/qml-qt-labs-platform-messagedialog.html). Same idiom — declarative component, `open()` to show, signal handlers to react.
```qml
Platform.MessageDialog {
id: confirmDelete
title: "Delete todo?"
text: `${todo.title} will be removed permanently.`
buttons: Platform.MessageDialog.Yes | Platform.MessageDialog.Cancel
onAccepted: todoModel.removeRow(index)
}
```
For a non-modal toast-style "Saved!" feel, use Qt Quick Controls' `ToolTip.show(text, timeoutMs)` rather than a MessageDialog — that's a *banner*, not a confirmation, and shouldn't grab focus.
## Notifications
For "X has finished" / "you have a new Y" *system tray* notifications, use [`Qt.labs.platform.SystemTrayIcon`](https://doc.qt.io/qt-6/qml-qt-labs-platform-systemtrayicon.html) with `showMessage(title, body, icon, msecs)`:
```qml
Platform.SystemTrayIcon {
id: tray
visible: true
icon.source: "qrc:/icons/app.png"
Connections {
target: ImportJobs
function onCompleted(job) {
tray.showMessage("Import finished", `${job.rowCount} rows`)
}
}
}
```
Caveats:
- Cross-platform but **routed through the tray**. Users who hide the tray won't see the notification on Linux (the [XDG Notifications portal](https://flatpak.github.io/xdg-desktop-portal/) is the long-term fix; planned for a later release).
- macOS: notifications go to the Notification Center even if the tray isn't visible — works without caveat.
- Windows: same, via the action center.
Richer notifications (action buttons, replies, persistent banners) need platform-specific code per OS and aren't in scope for v0.x. If your app needs them sooner, drop a `QtPlatformNotification` wrapper next to `BackendConnection` and surface it as a QML singleton — happy to merge.
## Folder pickers
Same `FileDialog` component, `fileMode: Platform.FileDialog.OpenDirectory`. The native dialog hides files automatically.
## Standard paths
Don't hard-code `~/Downloads` or `%USERPROFILE%`. Use [`Qt.labs.platform.StandardPaths`](https://doc.qt.io/qt-6/qml-qt-labs-platform-standardpaths.html):
```qml
Platform.FileDialog {
folder: Platform.StandardPaths.writableLocation(Platform.StandardPaths.DocumentsLocation)
}
```
These resolve to the OS-correct location (`XDG_DOCUMENTS_DIR`, `~/Documents`, the Documents knownfolder, etc.) and stay correct under user policy / corporate config.
## Anti-patterns
- **Don't** `POST /api/show-dialog`. The PHP side has no hook into the user's window manager.
- **Don't** open a `QDialog` from a Doctrine listener. Doctrine listeners run inside the FrankenPHP child process; even if they could reach Qt, they'd block the worker.
- **Don't** roll a custom QML "FileDialog" using `Window` + `ListView` to browse the filesystem. It looks wrong on every OS, can't access OS-restricted paths (sandboxed downloads, Photos, etc.), and reinvents what Qt already ships.
## When to publish a Mercure event vs open a dialog directly
- **User initiates an action that needs confirmation** (delete, overwrite) → open the dialog from the QML handler that fired the action; the action only proceeds on `onAccepted`.
- **Server-side event the user should be told about** (background job done, push notification) → publish a Mercure event on `app://event/<name>`; QML's `MercureClient` listener fires the dialog or `tray.showMessage`.
The split keeps the *trigger* close to the user's intent and the *side-effect* declarative on the QML side.
## See also
- [QML API reference — `BackendConnection`, `MercureClient`](qml-api.md) — for handling server-pushed events that drive notifications.
- [Update semantics](update-semantics.md) — for the `commandFailed` / `commandTimedOut` signals that often want toast or dialog feedback.

View File

@@ -117,6 +117,17 @@ void BackendConnection::applyUpdate() {
`appImage` is `qgetenv("APPIMAGE")` — the AppImage runtime exports this when an AppImage launches. Outside an AppImage the env is unset and both methods short-circuit with `updateCheckFailed("APPIMAGE env not set; not running from a packaged AppImage")`.
### Periodic check
The supervisor schedules `checkForUpdates()` automatically on the first `Online` transition (10 s after backend ready) and re-arms it every 6 hours by default. PLAN.md §11 *Auto-update* asked for "check on launch and once per N hours; offer install on next restart, never auto-restart" — the periodic check surfaces `updatesAvailable()` for an in-app banner; `applyUpdate()` is still the explicit user-driven trigger and there is no auto-restart.
Two env vars tune it (see [Bundled mode §periodic check](bundled-mode.md#periodic-check)):
- `BRIDGE_AUTO_UPDATE_DISABLE=1` — skip the periodic poll (Q_INVOKABLE methods still work).
- `BRIDGE_AUTO_UPDATE_PERIOD_MIN=<minutes>` — override the default 360 minutes.
Dev mode skips the periodic check entirely.
### Appcast (`latest.json`)
CI publishes a `latest.json` next to the release artefacts:

View File

@@ -15,9 +15,13 @@ return [
| Symbol | Kind | Use it when… |
| --- | --- | --- |
| [`#[BridgeResource]`](#bridgeresource-attribute) | PHP attribute | You want a Doctrine entity to dual-publish on Mercure automatically. |
| [`BridgeOp`](#bridgeop-enum) | Enum | You're calling `ModelPublisher::publishEntityChange` directly. |
| [`PublisherInterface`](#publisherinterface) / [`ModelPublisherInterface`](#modelpublisherinterface) / [`CorrelationContextInterface`](#correlationcontextinterface) | Interfaces | You're typehinting bridge services in your own controllers / listeners. |
| [`ModelPublisher`](#modelpublisher) | Service | You want to publish a custom event without persist/update/remove. |
| [`CorrelationContext`](#correlationcontext) | Service | You're inside a non-controller code path and need the current request's `Idempotency-Key`. |
| [`BridgeBundleInfo`](#bridgebundleinfo) | Value object | You want a deep-load canary on the bundle (e.g. a custom `/healthz`). |
| [`bridge:doctor`](#bridgedoctor) | Console command | You want to verify the dev environment is wired correctly. |
| [`bridge:export`](#bridgeexport) | Console command | You want to copy the active SQLite database to a user-chosen path. |
| [`SessionAuthenticator`](#sessionauthenticator) | Security authenticator | (Internal) checks the `Authorization: Bearer` header against `BRIDGE_TOKEN`. |
| [`CorrelationKeyListener`](#correlationkeylistener) | Event subscriber | (Internal) reads `Idempotency-Key` into `CorrelationContext`. |
@@ -56,6 +60,26 @@ After tagging, every `postPersist` / `postUpdate` / `postRemove` event triggers
The maker (`make:bridge:resource`) attaches this attribute automatically.
### `BridgeOp` enum
Wire-format enum for `op` field on every Mercure event the bundle publishes:
```php
namespace PhpQml\Bridge;
enum BridgeOp: string
{
case Upsert = 'upsert';
case Delete = 'delete';
case Replace = 'replace';
case Event = 'event';
}
```
The string values are the on-the-wire format — QML clients hardcode them, so renaming a case (without changing its `value`) is safe; changing a `value` is a wire-protocol break (and `BridgeOpTest` will fail the build before it ships).
You only deal with this directly when calling `ModelPublisher::publishEntityChange` from a custom code path. The Doctrine subscriber, the makers, and the `#[BridgeResource]` plumbing pick the right case for you.
### Custom resource name
```php
@@ -73,20 +97,67 @@ Topics become `app://model/task` and `app://model/task/<id>`. Useful when the st
---
## `ModelPublisher`
## `PublisherInterface`
Service that does the dual-publish. Auto-fired by the bundle's Doctrine subscriber on `postPersist` / `postUpdate` / `postRemove` for any `#[BridgeResource]` entity. Inject it directly when you want to publish a *custom* event (e.g. progress on a long-running command).
Thin Mercure facade. Concrete implementation: `Publisher` (autowired). Typehint the interface in app code so a swappable implementation (e.g. an offline-buffer publisher that queues events when Mercure is unreachable) stays non-breaking.
```php
namespace PhpQml\Bridge;
final class ModelPublisher
interface PublisherInterface
{
public function publishEntityChange(object $entity, string $op): void;
public function publish(string $topic, array $data): void;
}
```
`$op` is one of `"upsert"` / `"delete"`. The published JSON payload:
Mirrors upstream Symfony's `HubInterface`/`Hub` split. Existing call sites that typehint the concrete `Publisher` class keep working — autowire continues to inject the concrete implementation transparently.
## `ModelPublisherInterface`
The dual-publish surface, one level up from `PublisherInterface`. Concrete implementation: `ModelPublisher`.
```php
namespace PhpQml\Bridge;
interface ModelPublisherInterface
{
public function publishEntityChange(object $entity, BridgeOp $op): void;
}
```
`DoctrineBridgeListener` typehints this interface, not the concrete class.
## `CorrelationContextInterface`
Request-scoped key holder; concrete implementation: `CorrelationContext`. See [`CorrelationContext`](#correlationcontext) for the contract.
```php
namespace PhpQml\Bridge;
interface CorrelationContextInterface
{
public function set(?string $key): void;
public function get(): ?string;
public function clear(): void;
}
```
## `ModelPublisher`
Service that does the dual-publish. Auto-fired by the bundle's Doctrine subscriber on `postPersist` / `postUpdate` / `postRemove` for any `#[BridgeResource]` entity. Inject it (or `ModelPublisherInterface`) directly when you want to publish a *custom* event (e.g. progress on a long-running command).
```php
namespace PhpQml\Bridge;
final class ModelPublisher implements ModelPublisherInterface
{
public function publishEntityChange(object $entity, BridgeOp $op): void;
}
```
> **API break in v0.2.0:** the second arg used to be `string $op`. It is now the typed `BridgeOp` enum — typo'd ops are caught at compile time instead of silently producing envelopes clients ignore. Migration: replace raw `'upsert'` / `'delete'` strings with `BridgeOp::Upsert` / `BridgeOp::Delete`.
`$op` is one of the `BridgeOp` cases. The published JSON payload:
```json
{
@@ -106,15 +177,15 @@ final class ModelPublisher
final class MarkAllDoneController
{
public function __construct(
private EntityManagerInterface $em,
private ModelPublisher $publisher,
private EntityManagerInterface $em,
private ModelPublisherInterface $publisher,
) {}
public function __invoke(): JsonResponse
{
foreach ($this->em->getRepository(Todo::class)->findAll() as $todo) {
$todo->setDone(true);
$this->publisher->publishEntityChange($todo, 'upsert');
$this->publisher->publishEntityChange($todo, BridgeOp::Upsert);
}
$this->em->flush();
return new JsonResponse(['ok' => true]);
@@ -128,25 +199,14 @@ In practice you usually don't need to call `publishEntityChange` manually — `f
## `CorrelationContext`
Request-scoped service holding the current `Idempotency-Key`. The bundle's `CorrelationKeyListener` reads the request header into it; `ModelPublisher` reads it back when emitting events.
```php
namespace PhpQml\Bridge;
final class CorrelationContext
{
public function set(?string $key): void;
public function get(): ?string;
public function clear(): void;
}
```
Request-scoped service holding the current `Idempotency-Key`. The bundle's `CorrelationKeyListener` reads the request header into it; `ModelPublisher` reads it back when emitting events. Implements [`CorrelationContextInterface`](#correlationcontextinterface).
You rarely need to touch this — it auto-plumbs the `correlationKey` field on every `ModelPublisher` event. Inject it if you're writing a custom controller that publishes through some other mechanism and wants to thread the same key through.
```php
final class CustomController
{
public function __invoke(CorrelationContext $ctx, /* … */): JsonResponse
public function __invoke(CorrelationContextInterface $ctx, /* … */): JsonResponse
{
$key = $ctx->get(); // → "01HX…" (uuid set by the QML client)
// …
@@ -156,6 +216,34 @@ final class CustomController
---
## `BridgeBundleInfo`
Value object carrying the bundle's name + class FQCN. Used by `HealthController` as the deep-load canary on `/healthz` — if the container can construct `BridgeBundleInfo`, the bundle is wired up correctly.
```php
namespace PhpQml\Bridge;
final readonly class BridgeBundleInfo
{
public function __construct(
public string $name, // 'php-qml/bridge'
public string $class, // PhpQml\Bridge\BridgeBundle::class
) {}
}
```
App code rarely injects this directly — but if you're rolling a custom `/healthz` endpoint and want the same canary semantic without coupling to `Publisher` (which `/healthz` used to do pre-v0.2.0), this is the shape to typehint.
`/healthz` response shape (changed in v0.2.0):
```json
{ "status": "ok", "name": "php-qml/bridge", "bundle": "PhpQml\\Bridge\\BridgeBundle" }
```
Pre-v0.2.0 the `bundle` field was `PhpQml\Bridge\Publisher`. Consumers asserting that exact value need to migrate; consumers reading any-truthy / unknown-keys-ok are unaffected.
---
## `bridge:doctor`
Console command. Verifies a dev environment is set up correctly.
@@ -184,6 +272,26 @@ Exit code `0` if everything passes, non-zero otherwise. CI runs this as part of
---
## `bridge:export`
Console command. Copies the active SQLite database to a user-chosen path.
```bash
bin/console bridge:export /home/me/backup-2026-05-03.sqlite
# → wrote 1245184 bytes to /home/me/backup-2026-05-03.sqlite
```
Behaviour:
- Reads the source path from `DATABASE_URL`. Works in dev and bundled mode without configuration.
- Overwrites the destination if it exists.
- Errors with exit code `1` if `DATABASE_URL` doesn't point at a SQLite file (`sqlite:///…`), or the source file doesn't exist.
- Mirrored on the QML side as [`BackendConnection.exportDatabase(path)`](qml-api.md#exportdatabase) — apps typically pair the QML hook with `Qt.labs.platform.FileDialog` so the user picks a destination natively (see [Native dialogs §file pickers](native-dialogs.md#file-pickers)).
This is the export half of a "backup my data" UX. The restore half is just `cp <backup> <data-dir>/data.sqlite` while the app is closed; bundled mode also keeps automatic [pre-migration backups](bundled-mode.md#pre-migration-auto-backup) for the migration-corruption case.
---
## Event subscribers
These run automatically; documented for awareness.
@@ -215,7 +323,12 @@ If you want to layer real user authentication on top (e.g. an app that has multi
```
framework/php/
├── src/
│ ├── BridgeBundle.php bundle registration
│ ├── BridgeBundle.php bundle registration + DI extension
│ ├── BridgeBundleInfo.php deep-load canary value object
│ ├── BridgeOp.php wire-format enum
│ ├── PublisherInterface.php ─┐
│ ├── ModelPublisherInterface.php │ public service interfaces
│ ├── CorrelationContextInterface.php ─┘
│ ├── Attribute/BridgeResource.php
│ ├── ModelPublisher.php dual-publish + version increment
│ ├── Publisher.php thin Mercure facade
@@ -225,9 +338,12 @@ framework/php/
│ │ └── CorrelationKeyListener.php request → context
│ ├── EventListener/ Doctrine + Symfony listeners
│ ├── Command/
│ │ ── BridgeDoctorCommand.php bridge:doctor
│ │ ── BridgeDoctorCommand.php bridge:doctor
│ │ └── BridgeExportCommand.php bridge:export
│ ├── Controller/ (skeleton route resource lives here)
── Maker/ symfony/maker-bundle integrations
── Maker/ symfony/maker-bundle integrations
│ │ └── Support/ shared helpers (NameInput, Naming)
│ └── ReadModel/ (apps' read-model query services land here)
├── config/services.yaml service wiring
└── tests/ unit + integration + maker snapshot
```

View File

@@ -38,8 +38,9 @@ QML singleton. Lifecycle owner: detects dev vs bundled mode at construction, sup
| Method | Description |
| --- | --- |
| `restart()` | Bundled mode: tear down + respawn FrankenPHP. Dev mode: re-probe. |
| `checkForUpdates()` | Bundled mode: invoke AppImageUpdate sidecar `--check-for-update`. |
| `applyUpdate()` | Bundled mode: invoke AppImageUpdate sidecar `--remove-old`. |
| `checkForUpdates()` | Bundled mode: invoke AppImageUpdate sidecar `--check-for-update`. The supervisor also calls this automatically on launch (10 s after `Online`) and every 6 h thereafter — see [Bundled mode §periodic check](bundled-mode.md#periodic-check). |
| `applyUpdate()` | Bundled mode: invoke AppImageUpdate sidecar `--remove-old`. Never auto-restarts the app. |
| `exportDatabase(path)` | `Q_INVOKABLE bool`. Copies the active SQLite database to `path`; returns success synchronously and emits `databaseExported(path)` / `databaseExportFailed(reason)` for async UX. Mirrors the `bridge:export` console command. See below. |
| `childLogTail()` | Bundled mode: returns `QStringList` of last ≤500 child output lines. |
### Signals
@@ -53,6 +54,8 @@ QML singleton. Lifecycle owner: detects dev vs bundled mode at construction, sup
| `updateCheckFailed(QString reason)` | Sidecar errored, env unset, or dev mode. |
| `updateApplied()` | Update was downloaded and applied; user should restart. |
| `updateApplyFailed(QString reason)` | Apply errored. |
| `databaseExported(QString path)` | `exportDatabase()` succeeded. |
| `databaseExportFailed(QString reason)` | `exportDatabase()` errored (non-SQLite `DATABASE_URL`, missing source, write failed). |
| `childLogLine(QString line)` | Emitted per line read from the bundled child's merged stdout+stderr. |
### Example
@@ -72,6 +75,32 @@ Item {
}
```
### `exportDatabase`
Pair with `Qt.labs.platform.FileDialog` so the user picks a destination natively:
```qml
import Qt.labs.platform as Platform
Platform.FileDialog {
id: saveDlg
title: "Export database"
fileMode: Platform.FileDialog.SaveFile
nameFilters: ["SQLite (*.sqlite)"]
onAccepted: BackendConnection.exportDatabase(Qt.url.toLocalFile(currentFile))
}
Connections {
target: BackendConnection
function onDatabaseExported(path) { tray.showMessage("Saved", path) }
function onDatabaseExportFailed(reason) { error.text = reason }
}
Button { text: "Export…"; onClicked: saveDlg.open() }
```
`exportDatabase()` returns synchronously (`true` on success, `false` on failure) — the signals exist for cases where the caller is decoupled from the click handler. See [PHP API §bridge:export](php-api.md#bridgeexport) for the equivalent CLI command.
---
## `RestClient`

View File

@@ -83,8 +83,14 @@ appimage: build staging-symfony ## Package as a single-file Linux AppImage at bu
@echo "AppImage built. Test with: ./build/Todo-x86_64.AppImage"
.PHONY: quality
quality: build ## Run PHPStan, php-cs-fixer (check), PHPUnit, qmllint, integration (dev + bundled)
quality: build qmltest ## Run PHPStan, php-cs-fixer (check), PHPUnit, qmllint, qmltest, integration (dev + bundled)
cd ../../framework/php && composer quality
cmake --build $(BUILD_DIR) --target all_qmllint
./tests/integration.sh
$(MAKE) integration-bundled
.PHONY: qmltest
qmltest: ## Run QML unit tests (Qt::QuickTest via qmltestrunner)
cmake -S ../../framework/qml -B ../../framework/qml/build-tests -DBUILD_TESTING=ON
cmake --build ../../framework/qml/build-tests --target qml_unit_tests --parallel
QT_QPA_PLATFORM=offscreen ctest --test-dir ../../framework/qml/build-tests --output-on-failure -R qml_unit_tests

View File

@@ -11,6 +11,7 @@
"symfony/security-bundle": "^8.0",
"symfony/mercure-bundle": "^0.4",
"symfony/uid": "^8.0",
"symfony/validator": "^8.0",
"doctrine/orm": "^3.0",
"doctrine/doctrine-bundle": "^3.0",
"doctrine/doctrine-migrations-bundle": "^4.0",

View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "50ef8ab49885db8d3709edc1c8e68e05",
"content-hash": "d001a6d1e30f94b4b5044262009031fc",
"packages": [
{
"name": "doctrine/collections",
@@ -1199,13 +1199,13 @@
"dist": {
"type": "path",
"url": "../../../framework/php",
"reference": "68fca95525db2311a08deb931f1b92909b20c450"
"reference": "b426d4a8ca67cde4f3bd0471d340e348b1fd4053"
},
"require": {
"doctrine/dbal": "^4.0",
"doctrine/doctrine-bundle": "^3.0",
"doctrine/orm": "^3.0",
"php": "^8.3",
"php": "^8.4",
"symfony/config": "^8.0",
"symfony/console": "^8.0",
"symfony/dependency-injection": "^8.0",
@@ -1259,7 +1259,7 @@
]
},
"license": [
"proprietary"
"LGPL-3.0-or-later"
],
"description": "Symfony bundle bridging PHP applications to a Qt/QML host (part of the php-qml framework).",
"transport-options": {
@@ -5024,6 +5024,88 @@
],
"time": "2026-03-30T15:14:47+00:00"
},
{
"name": "symfony/translation-contracts",
"version": "v3.6.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/translation-contracts.git",
"reference": "65a8bc82080447fae78373aa10f8d13b38338977"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/translation-contracts/zipball/65a8bc82080447fae78373aa10f8d13b38338977",
"reference": "65a8bc82080447fae78373aa10f8d13b38338977",
"shasum": ""
},
"require": {
"php": ">=8.1"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/contracts",
"name": "symfony/contracts"
},
"branch-alias": {
"dev-main": "3.6-dev"
}
},
"autoload": {
"psr-4": {
"Symfony\\Contracts\\Translation\\": ""
},
"exclude-from-classmap": [
"/Test/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Generic abstractions related to translation",
"homepage": "https://symfony.com",
"keywords": [
"abstractions",
"contracts",
"decoupling",
"interfaces",
"interoperability",
"standards"
],
"support": {
"source": "https://github.com/symfony/translation-contracts/tree/v3.6.1"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2025-07-15T13:41:35+00:00"
},
{
"name": "symfony/type-info",
"version": "v8.0.9",
@@ -5184,6 +5266,101 @@
],
"time": "2026-04-30T16:10:06+00:00"
},
{
"name": "symfony/validator",
"version": "v8.0.9",
"source": {
"type": "git",
"url": "https://github.com/symfony/validator.git",
"reference": "131dc8322c06595a6c98185787fa756deada20df"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/validator/zipball/131dc8322c06595a6c98185787fa756deada20df",
"reference": "131dc8322c06595a6c98185787fa756deada20df",
"shasum": ""
},
"require": {
"php": ">=8.4",
"symfony/polyfill-ctype": "^1.8",
"symfony/polyfill-mbstring": "^1.0",
"symfony/translation-contracts": "^2.5|^3"
},
"conflict": {
"doctrine/lexer": "<1.1",
"symfony/doctrine-bridge": "<7.4",
"symfony/expression-language": "<7.4"
},
"require-dev": {
"egulias/email-validator": "^2.1.10|^3|^4",
"symfony/cache": "^7.4|^8.0",
"symfony/config": "^7.4|^8.0",
"symfony/console": "^7.4|^8.0",
"symfony/dependency-injection": "^7.4|^8.0",
"symfony/expression-language": "^7.4|^8.0",
"symfony/finder": "^7.4|^8.0",
"symfony/http-client": "^7.4|^8.0",
"symfony/http-foundation": "^7.4|^8.0",
"symfony/http-kernel": "^7.4|^8.0",
"symfony/intl": "^7.4|^8.0",
"symfony/mime": "^7.4|^8.0",
"symfony/process": "^7.4|^8.0",
"symfony/property-access": "^7.4|^8.0",
"symfony/property-info": "^7.4|^8.0",
"symfony/string": "^7.4|^8.0",
"symfony/translation": "^7.4|^8.0",
"symfony/type-info": "^7.4|^8.0",
"symfony/yaml": "^7.4|^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\Validator\\": ""
},
"exclude-from-classmap": [
"/Tests/",
"/Resources/bin/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Provides tools to validate values",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/validator/tree/v8.0.9"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-04-30T16:10:06+00:00"
},
{
"name": "symfony/var-dumper",
"version": "v8.0.8",
@@ -5743,7 +5920,7 @@
"prefer-stable": false,
"prefer-lowest": false,
"platform": {
"php": "^8.3"
"php": "^8.4"
},
"platform-dev": {},
"plugin-api-version": "2.6.0"

View File

@@ -37,6 +37,10 @@ STAGING="$APP_DIR/build/staging-symfony"
CADDYFILE="$APP_DIR/Caddyfile"
APP_NAME=todo
# Force a known port so the test can pre-check / curl without
# parsing the sentinel file. The supervisor honours BRIDGE_PORT
# in bundled mode (PLAN.md §13 v0.2.0 *Port negotiation*); in
# real-world use it negotiates a free ephemeral port instead.
PORT=8765
step() { echo "$*"; }
@@ -52,6 +56,8 @@ if (echo > "/dev/tcp/127.0.0.1/$PORT") 2>/dev/null; then
skip "port $PORT already in use (dev instance running?)"
fi
export BRIDGE_PORT="$PORT"
# ── Stage a fake AppImage layout in a temp dir ─────────────────────────
ROOT="$(mktemp -d)"
DATA_DIR="$(mktemp -d)"
@@ -122,8 +128,10 @@ done
step "/healthz body: $HEALTHZ_BODY"
echo "$HEALTHZ_BODY" | grep -q '"status":"ok"' \
|| fail "/healthz didn't return status:ok"
echo "$HEALTHZ_BODY" | grep -q '"bundle":"PhpQml\\\\Bridge\\\\Publisher"' \
echo "$HEALTHZ_BODY" | grep -q '"bundle":"PhpQml\\\\Bridge\\\\BridgeBundle"' \
|| fail "/healthz missing bundle field — HealthController deep-load broken"
echo "$HEALTHZ_BODY" | grep -q '"name":"php-qml\\/bridge"' \
|| fail "/healthz missing name field — BridgeBundleInfo not wired"
# ── Verify the cache/log redirect actually fired ───────────────────────
step "verify Symfony wrote cache to user data dir, not the read-only staging"
@@ -190,6 +198,25 @@ done
echo "$HEALTHZ2_BODY" | grep -q '"status":"ok"' \
|| fail "2nd-launch /healthz didn't return status:ok"
# ── Pre-migration auto-backup ─────────────────────────────────────────
# Launch 1 created data.sqlite; launch 2 should have copied it to
# data.sqlite.<unix-timestamp>.bak before re-running migrations.
# ── Port-negotiation sentinel ─────────────────────────────────────────
step "verify port sentinel was written"
SENTINEL="$USER_DATA/var/bridge.port"
[ -f "$SENTINEL" ] || fail "expected $SENTINEL after first launch"
SENTINEL_PORT="$(cat "$SENTINEL" | tr -d '[:space:]')"
[ "$SENTINEL_PORT" = "$PORT" ] || fail "sentinel port mismatch: got '$SENTINEL_PORT', expected '$PORT'"
step "verify pre-migration backup of data.sqlite was written"
shopt -s nullglob
backups=( "$USER_DATA"/var/data.sqlite.*.bak )
shopt -u nullglob
if [ "${#backups[@]}" -eq 0 ]; then
fail "expected at least one data.sqlite.*.bak under $USER_DATA/var after 2nd launch"
fi
step "found backup: ${backups[0]}"
# ── Clean shutdown: SIGTERM the host, assert no Qt warning + no orphan frankenphp ──
step "graceful shutdown — assert the supervisor kills its frankenphp child"
SHUTDOWN_PID="$PID"

View File

@@ -61,6 +61,10 @@ export XDG_DATA_HOME="$DATA_DIR/share"
export XDG_CACHE_HOME="$DATA_DIR/cache"
export APPIMAGE_EXTRACT_AND_RUN=1
mkdir -p "$XDG_DATA_HOME" "$XDG_CACHE_HOME"
# Force the port so the curl probe below can hit a known address —
# the supervisor would otherwise negotiate a free ephemeral port and
# we'd have to read it back from the sentinel file.
export BRIDGE_PORT="$PERF_BACKEND_PORT"
step "launching AppImage (${RUNNER[*]:-direct})"
START_NS=$(date +%s%N)

View File

@@ -41,3 +41,11 @@ when@dev:
PhpQml\Bridge\Maker\BridgeWindowMaker:
arguments:
$qmlPath: '%bridge.qml_path%'
PhpQml\Bridge\Maker\BridgeEventMaker:
arguments:
$qmlPath: '%bridge.qml_path%'
PhpQml\Bridge\Maker\BridgeReadModelMaker:
arguments:
$qmlPath: '%bridge.qml_path%'

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace PhpQml\Bridge;
/**
* Tiny readonly value-object that names the bundle. Exists for one
* reason: `HealthController` constructor-injects it as a deep-load
* canary. If the bundle's autoload or container wiring is broken
* (a dangling vendor path-repo symlink in a packaging build, for
* example), this service can't be resolved and `/healthz` fails 500
* — instead of misleadingly returning 200 against a half-loaded
* bundle, which is what bit v0.1.0.
*
* Decoupled from `PublisherInterface` (the v0.1.1 canary) so the
* publisher's contract stays free to evolve without rippling into
* the readiness probe's expectations.
*/
final readonly class BridgeBundleInfo
{
public string $name;
public string $bundle;
public function __construct()
{
$this->name = 'php-qml/bridge';
$this->bundle = BridgeBundle::class;
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace PhpQml\Bridge;
/**
* The four `op` values used in the bridge's Mercure envelopes (PLAN.md §4).
*
* String-backed so `$op->value` is exactly the wire-format token QML
* clients see. Encoded as an enum (rather than `string` parameters) so
* the typo `'upsret'` is caught at the type level instead of producing
* an envelope clients silently ignore.
*/
enum BridgeOp: string
{
/** Entity created or updated. */
case Upsert = 'upsert';
/** Entity removed. */
case Delete = 'delete';
/** Whole-collection replacement (e.g. server-side reset / re-seed). */
case Replace = 'replace';
/** Domain event on `app://event/{name}` topic — not tied to a model row. */
case Event = 'event';
}

View File

@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace PhpQml\Bridge\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Copies the active SQLite database to a destination path. Paired with
* the QML `BackendConnection.exportDatabase()` hook so end users can
* "Export my data" before a machine move or before authorising a
* risky migration.
*
* Driven from `DATABASE_URL` so it works identically in dev mode
* (developer's source-tree var/data.sqlite) and bundled mode (user
* data dir under XDG/Library/AppData). Non-SQLite drivers are out
* of scope — exposing them would require driver-specific dump
* tooling and the framework is single-instance SQLite-first by
* design (PLAN.md §6).
*/
#[AsCommand(
name: 'bridge:export',
description: 'Copy the SQLite database to a destination path (snapshot, not a live dump).',
)]
final class BridgeExportCommand extends Command
{
public function __construct(
#[Autowire('%env(default::DATABASE_URL)%')]
private readonly string $databaseUrl,
) {
parent::__construct();
}
protected function configure(): void
{
$this->addArgument(
'destination',
InputArgument::REQUIRED,
'Where to write the exported file (overwrites if it exists).',
);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$source = $this->resolveSqlitePath();
if (null === $source) {
$io->error([
'DATABASE_URL is not a sqlite:/// URL.',
'bridge:export only handles SQLite — other drivers need driver-specific dumps.',
]);
return Command::FAILURE;
}
if (!is_file($source)) {
$io->error("SQLite file does not exist: {$source}");
return Command::FAILURE;
}
$destination = (string) $input->getArgument('destination');
if ('' === $destination) {
$io->error('Destination path cannot be empty.');
return Command::FAILURE;
}
// Snapshot semantics: for a *running* application, SQLite's WAL
// journal may hold uncommitted writes that a plain copy misses.
// The desktop case is single-process, single-writer; flushing
// is a no-op here (Doctrine commits per request, no long-running
// transactions). If that ever changes, swap to `sqlite3_backup`
// via PDO::sqliteCreateFunction for a consistent online copy.
if (!@copy($source, $destination)) {
$err = error_get_last()['message'] ?? 'unknown error';
$io->error("Could not copy {$source}{$destination}: {$err}");
return Command::FAILURE;
}
$io->success("Exported {$source}{$destination}.");
return Command::SUCCESS;
}
private function resolveSqlitePath(): ?string
{
// parse_url() rejects `sqlite:///abs/path` (host-less triple-slash)
// on PHP 8.5+, so do the strip manually. Doctrine's DBAL only
// emits two URL shapes for SQLite: sqlite:///abs/path (absolute,
// PLAN.md §3 *Startup* uses this) and sqlite://relative/path.
if (str_starts_with($this->databaseUrl, 'sqlite:///')) {
return '/'.substr($this->databaseUrl, \strlen('sqlite:///'));
}
if (str_starts_with($this->databaseUrl, 'sqlite://')) {
return substr($this->databaseUrl, \strlen('sqlite://'));
}
return null;
}
}

View File

@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace PhpQml\Bridge\Controller;
use PhpQml\Bridge\Publisher;
use PhpQml\Bridge\BridgeBundleInfo;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
@@ -12,16 +12,18 @@ use Symfony\Component\Routing\Attribute\Route;
* Readiness probe used by the Qt host to detect when the backend is up.
* See PLAN.md §3 (*Startup*, step 4).
*
* Publisher is injected purely as a deep-health canary: if the bridge
* `BridgeBundleInfo` is injected purely as a deep-load canary: if the
* bundle's autoload or container wiring is broken (e.g. a packaging build
* with a dangling vendor path-repo symlink), this controller can't even
* be constructed, so /healthz fails 500 instead of misleadingly returning
* 200 against a half-loaded bundle.
* 200 against a half-loaded bundle. Earlier (v0.1.1v0.2.0) this canary
* was `PublisherInterface`; switching to a dedicated info VO decouples
* the readiness probe from the publisher's evolving contract.
*/
final class HealthController
{
public function __construct(
private readonly Publisher $publisher,
private readonly BridgeBundleInfo $info,
) {
}
@@ -30,7 +32,8 @@ final class HealthController
{
return new JsonResponse([
'status' => 'ok',
'bundle' => $this->publisher::class,
'bundle' => $this->info->bundle,
'name' => $this->info->name,
]);
}
}

View File

@@ -5,17 +5,18 @@ declare(strict_types=1);
namespace PhpQml\Bridge;
/**
* Per-request correlation key holder.
* Default implementation of {@see CorrelationContextInterface}: a plain
* per-request holder for the `Idempotency-Key` value.
*
* The HTTP request's `Idempotency-Key` (PLAN.md §4 *Idempotency*) is
* stashed here on RequestEvent and read back by ModelPublisher when
* it builds Mercure envelopes, so QML clients can match Mercure echoes
* to the optimistic mutation that originated them (§5).
* Stashed here on RequestEvent (see {@see EventSubscriber\CorrelationKeyListener})
* and read back by {@see ModelPublisher} when it builds Mercure envelopes,
* so QML clients can match Mercure echoes to the optimistic mutation that
* originated them (PLAN.md §4 *Idempotency*, §5 *Optimistic updates*).
*
* Cleared on TerminateEvent. CLI commands and out-of-request mutations
* see no correlation key, which is the correct behaviour.
*/
final class CorrelationContext
final class CorrelationContext implements CorrelationContextInterface
{
private ?string $key = null;

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace PhpQml\Bridge;
/**
* Per-request holder for the `Idempotency-Key` echoed back as the
* `correlationKey` field in Mercure envelopes (PLAN.md §4 *Idempotency*,
* §5 *Optimistic updates*).
*
* The public API surface of `CorrelationContext`. Apps can swap the
* implementation if they need request-scoped storage with different
* semantics (e.g. message-bus stamps for async dispatch).
*/
interface CorrelationContextInterface
{
public function set(?string $key): void;
public function get(): ?string;
public function clear(): void;
}

View File

@@ -9,7 +9,8 @@ use Doctrine\ORM\Event\PostPersistEventArgs;
use Doctrine\ORM\Event\PostRemoveEventArgs;
use Doctrine\ORM\Event\PostUpdateEventArgs;
use Doctrine\ORM\Events;
use PhpQml\Bridge\ModelPublisher;
use PhpQml\Bridge\BridgeOp;
use PhpQml\Bridge\ModelPublisherInterface;
/**
* Bridges Doctrine entity lifecycle events to Mercure publishes.
@@ -23,22 +24,22 @@ use PhpQml\Bridge\ModelPublisher;
final readonly class DoctrineBridgeListener
{
public function __construct(
private ModelPublisher $modelPublisher,
private ModelPublisherInterface $modelPublisher,
) {
}
public function postPersist(PostPersistEventArgs $args): void
{
$this->modelPublisher->publishEntityChange($args->getObject(), 'upsert');
$this->modelPublisher->publishEntityChange($args->getObject(), BridgeOp::Upsert);
}
public function postUpdate(PostUpdateEventArgs $args): void
{
$this->modelPublisher->publishEntityChange($args->getObject(), 'upsert');
$this->modelPublisher->publishEntityChange($args->getObject(), BridgeOp::Upsert);
}
public function postRemove(PostRemoveEventArgs $args): void
{
$this->modelPublisher->publishEntityChange($args->getObject(), 'delete');
$this->modelPublisher->publishEntityChange($args->getObject(), BridgeOp::Delete);
}
}

View File

@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace PhpQml\Bridge\Maker;
use Doctrine\ORM\EntityManagerInterface;
use PhpQml\Bridge\Maker\Support\NameInput;
use PhpQml\Bridge\Maker\Support\Naming;
use Symfony\Bundle\MakerBundle\ConsoleStyle;
use Symfony\Bundle\MakerBundle\DependencyBuilder;
use Symfony\Bundle\MakerBundle\Generator;
@@ -61,23 +63,20 @@ final class BridgeCommandMaker extends AbstractMaker
public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void
{
if (null === $input->getArgument('name')) {
$name = $io->ask('Command name (CamelCase)?', null, static function (?string $v): string {
if (null === $v || '' === trim($v)) {
throw new \RuntimeException('Command name cannot be empty.');
}
return ucfirst(trim($v));
});
$input->setArgument('name', $name);
}
NameInput::askOrFail(
$input,
$io,
'name',
'Command name (CamelCase)?',
'Command name cannot be empty.',
);
}
public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void
{
$rawName = (string) $input->getArgument('name');
$singular = ucfirst(Str::asCamelCase($rawName));
$kebab = strtolower(preg_replace('/(?<!^)[A-Z]/', '-$0', $singular) ?? $singular);
$kebab = Naming::camelTo($singular, '-');
$route = '/api/'.$kebab;
$controllerFqcn = $generator->createClassNameDetails(

View File

@@ -0,0 +1,140 @@
<?php
declare(strict_types=1);
namespace PhpQml\Bridge\Maker;
use PhpQml\Bridge\Maker\Support\NameInput;
use PhpQml\Bridge\Maker\Support\Naming;
use PhpQml\Bridge\PublisherInterface;
use Symfony\Bundle\MakerBundle\ConsoleStyle;
use Symfony\Bundle\MakerBundle\DependencyBuilder;
use Symfony\Bundle\MakerBundle\Generator;
use Symfony\Bundle\MakerBundle\InputConfiguration;
use Symfony\Bundle\MakerBundle\Maker\AbstractMaker;
use Symfony\Bundle\MakerBundle\Str;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* `make:bridge:event <Name>` — generates the three files that wire a
* domain event onto the bridge's `app://event/{name}` Mercure topic:
*
* - src/Event/<Name>Event.php — readonly event value object
* - src/EventSubscriber/<Name>Subscriber.php — listens, republishes via PublisherInterface
* - {qml_path}/<Name>EventHandler.qml — QML stub that re-emits as a typed signal
*
* Topic shape per PLAN.md §4: `app://event/<kebab-case-of-name>`. The
* generated stub publishes envelopes with `op: "event"` so QML clients
* can dispatch on op alongside `upsert` / `delete` / `replace`.
*/
final class BridgeEventMaker extends AbstractMaker
{
public function __construct(
private readonly string $qmlPath = '../qml/',
) {
}
public static function getCommandName(): string
{
return 'make:bridge:event';
}
public static function getCommandDescription(): string
{
return 'Generate a domain event class + subscriber + QML handler stub.';
}
public function configureCommand(Command $command, InputConfiguration $inputConfig): void
{
$command
->addArgument(
'name',
InputArgument::OPTIONAL,
'CamelCase event name (e.g. TodoCompleted, ImportFinished).',
)
->setHelp(
"Creates three files:\n\n"
." • <info>src/Event/<Name>Event.php</info> — readonly event value object\n"
." • <info>src/EventSubscriber/<Name>Subscriber.php</info> — republishes on app://event/<kebab-name>\n"
." • <info>{qml_path}/<Name>EventHandler.qml</info> — QML stub re-emitting as a typed signal\n\n"
."Dispatch from PHP with <info>\$dispatcher->dispatch(new <Name>Event([...]))</info>.\n"
);
}
public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void
{
NameInput::askOrFail(
$input,
$io,
'name',
'Event name (CamelCase, e.g. TodoCompleted)?',
'Event name cannot be empty.',
);
}
public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void
{
$rawName = (string) $input->getArgument('name');
$singular = ucfirst(Str::asCamelCase($rawName));
$topic = Naming::camelTo($singular, '-');
$eventFqcn = $generator->createClassNameDetails(
$singular,
'Event\\',
'Event',
);
$subscriberFqcn = $generator->createClassNameDetails(
$singular,
'EventSubscriber\\',
'Subscriber',
);
$vars = [
'singular' => $singular,
'event_topic' => $topic,
'event_short' => $eventFqcn->getShortName(),
'event_fqcn' => $eventFqcn->getFullName(),
'subscriber_short' => $subscriberFqcn->getShortName(),
'subscriber_fqcn' => $subscriberFqcn->getFullName(),
'handler_method' => 'on'.$singular,
'signal_name' => lcfirst($singular),
];
$generator->generateFile(
'src/Event/'.$eventFqcn->getShortName().'.php',
__DIR__.'/templates/EventClass.tpl.php',
$vars,
);
$generator->generateFile(
'src/EventSubscriber/'.$subscriberFqcn->getShortName().'.php',
__DIR__.'/templates/EventSubscriber.tpl.php',
$vars,
);
$qmlTarget = rtrim($this->qmlPath, '/').'/'.$singular.'EventHandler.qml';
$generator->generateFile(
$qmlTarget,
__DIR__.'/templates/EventHandlerQml.tpl.php',
$vars,
);
$generator->writeChanges();
$this->writeSuccessMessage($io);
$io->text([
'Next:',
" • Replace the <info>payload</info> array on <info>{$eventFqcn->getShortName()}</info> with typed properties.",
" • Dispatch via <info>\$dispatcher->dispatch(new {$eventFqcn->getShortName()}(\$data))</info>.",
" • Use <info>{$singular}EventHandler.qml</info> in your QML to receive the echo.",
]);
}
public function configureDependencies(DependencyBuilder $dependencies): void
{
$dependencies->addClassDependency(EventSubscriberInterface::class, 'symfony/event-dispatcher');
$dependencies->addClassDependency(PublisherInterface::class, 'php-qml/bridge');
}
}

View File

@@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
namespace PhpQml\Bridge\Maker;
use Doctrine\ORM\EntityManagerInterface;
use PhpQml\Bridge\Maker\Support\NameInput;
use PhpQml\Bridge\Maker\Support\Naming;
use Symfony\Bundle\MakerBundle\ConsoleStyle;
use Symfony\Bundle\MakerBundle\DependencyBuilder;
use Symfony\Bundle\MakerBundle\Generator;
use Symfony\Bundle\MakerBundle\InputConfiguration;
use Symfony\Bundle\MakerBundle\Maker\AbstractMaker;
use Symfony\Bundle\MakerBundle\Str;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
/**
* `make:bridge:read-model <Name>` — generates a query-only projection.
*
* Read-models are server-side joined / aggregated views QML reads
* without going through a writable `#[BridgeResource]`. Three files:
*
* - src/ReadModel/<Name>ReadModel.php — query service stub
* - src/Controller/<Name>Controller.php — `GET /api/<kebab-plural>`
* - {qml_path}/<Name>List.qml — `ReactiveListModel` bound to the route,
* no Mercure topic (read-models aren't reactive — invalidation is
* driven by domain events; see `make:bridge:event`).
*/
final class BridgeReadModelMaker extends AbstractMaker
{
public function __construct(
private readonly string $qmlPath = '../qml/',
) {
}
public static function getCommandName(): string
{
return 'make:bridge:read-model';
}
public static function getCommandDescription(): string
{
return 'Generate a read-only projection (query service + GET controller + QML stub).';
}
public function configureCommand(Command $command, InputConfiguration $inputConfig): void
{
$command
->addArgument(
'name',
InputArgument::OPTIONAL,
'CamelCase projection name (e.g. TodoSummary, RecentActivity).',
)
->setHelp(
"Creates three files:\n\n"
." • <info>src/ReadModel/<Name>ReadModel.php</info> — query service (you fill in the body)\n"
." • <info>src/Controller/<Name>Controller.php</info> — GET handler at /api/<kebab-plural>\n"
." • <info>{qml_path}/<Name>List.qml</info> — ReactiveListModel bound to the route\n\n"
."Read-models are not auto-reactive. To refresh from server-side changes,\n"
."generate a paired domain event with <info>make:bridge:event</info>.\n"
);
}
public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void
{
NameInput::askOrFail(
$input,
$io,
'name',
'Projection name (CamelCase, e.g. TodoSummary)?',
'Projection name cannot be empty.',
);
}
public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void
{
$rawName = (string) $input->getArgument('name');
$singular = ucfirst(Str::asCamelCase($rawName));
$pluralCamel = Str::singularCamelCaseToPluralCamelCase($singular);
$resource = Naming::camelTo($singular, '_');
$route = '/api/'.Naming::camelTo($pluralCamel, '-');
$readModelFqcn = $generator->createClassNameDetails(
$singular,
'ReadModel\\',
'ReadModel',
);
$controllerFqcn = $generator->createClassNameDetails(
$singular,
'Controller\\',
'Controller',
);
$vars = [
'singular' => $singular,
'resource' => $resource,
'route' => $route,
'read_model_short' => $readModelFqcn->getShortName(),
'read_model_fqcn' => $readModelFqcn->getFullName(),
'controller_short' => $controllerFqcn->getShortName(),
];
$generator->generateFile(
'src/ReadModel/'.$readModelFqcn->getShortName().'.php',
__DIR__.'/templates/ReadModel.tpl.php',
$vars,
);
$generator->generateFile(
'src/Controller/'.$controllerFqcn->getShortName().'.php',
__DIR__.'/templates/ReadModelController.tpl.php',
$vars,
);
$qmlTarget = rtrim($this->qmlPath, '/').'/'.$singular.'List.qml';
$generator->generateFile(
$qmlTarget,
__DIR__.'/templates/ReadModelQml.tpl.php',
$vars,
);
$generator->writeChanges();
$this->writeSuccessMessage($io);
$io->text([
'Next:',
" • Implement <info>{$readModelFqcn->getShortName()}::query()</info>.",
" • Use <info>{$singular}List.qml</info> in your QML.",
' • Optionally pair with <info>make:bridge:event</info> for invalidation.',
]);
}
public function configureDependencies(DependencyBuilder $dependencies): void
{
$dependencies->addClassDependency(EntityManagerInterface::class, 'doctrine/orm');
$dependencies->addClassDependency(Route::class, 'symfony/routing');
$dependencies->addClassDependency(JsonResponse::class, 'symfony/http-foundation');
}
}

View File

@@ -7,6 +7,8 @@ namespace PhpQml\Bridge\Maker;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Mapping as ORM;
use PhpQml\Bridge\Attribute\BridgeResource;
use PhpQml\Bridge\Maker\Support\NameInput;
use PhpQml\Bridge\Maker\Support\Naming;
use Symfony\Bundle\MakerBundle\ConsoleStyle;
use Symfony\Bundle\MakerBundle\DependencyBuilder;
use Symfony\Bundle\MakerBundle\Generator;
@@ -22,6 +24,7 @@ use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Component\Uid\Uuid;
use Symfony\Component\Validator\Constraints\NotBlank;
/**
* `make:bridge:resource <Name>` — generates the three files needed to
@@ -69,38 +72,52 @@ final class BridgeResourceMaker extends AbstractMaker
InputOption::VALUE_NONE,
'Use auto-incrementing int IDs instead of the default UUIDv7.',
)
->addOption(
'with-dto',
null,
InputOption::VALUE_NONE,
'Generate Create<Name>Dto + Update<Name>Dto alongside the controller and dispatch via #[MapRequestPayload]. Requires symfony/validator.',
)
->setHelp(
"The maker creates three files:\n\n"
"The maker creates three files (or five with --with-dto):\n\n"
." • <info>src/Entity/Todo.php</info> — Doctrine entity tagged with #[BridgeResource]\n"
." • <info>src/Controller/TodoController.php</info> — CRUD on /api/todos\n"
." • <info>{qml_path}/TodoList.qml</info> — starter ReactiveListModel snippet\n\n"
."With <info>--with-dto</info> the controller dispatches via #[MapRequestPayload]\n"
."against generated Create/Update DTOs (validated, no if-isset stubs):\n\n"
." • <info>src/Dto/CreateTodoDto.php</info> — POST payload with #[Assert\\NotBlank] etc.\n"
." • <info>src/Dto/UpdateTodoDto.php</info> — PATCH payload (all fields nullable)\n\n"
."After the maker, run <info>bin/console make:migration</info> and apply it.\n"
);
}
public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void
{
if (null === $input->getArgument('name')) {
$name = $io->ask('What is the resource name (e.g. Todo)?', null, static function (?string $v): string {
if (null === $v || '' === trim($v)) {
throw new \RuntimeException('Resource name cannot be empty.');
}
return ucfirst(trim($v));
});
$input->setArgument('name', $name);
}
NameInput::askOrFail(
$input,
$io,
'name',
'What is the resource name (e.g. Todo)?',
'Resource name cannot be empty.',
);
}
public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void
{
$rawName = (string) $input->getArgument('name');
$useUuid = !(bool) $input->getOption('int-id');
$useDto = (bool) $input->getOption('with-dto');
if ($useDto && !class_exists(NotBlank::class)) {
$io->error('--with-dto requires symfony/validator. Run: composer require symfony/validator');
return;
}
$singular = ucfirst(Str::asCamelCase($rawName));
$pluralCamel = Str::singularCamelCaseToPluralCamelCase($singular);
$resource = strtolower($singular);
$pluralUnder = strtolower(preg_replace('/(?<!^)[A-Z]/', '_$0', $pluralCamel) ?? $pluralCamel);
$pluralUnder = Naming::camelTo($pluralCamel, '_');
$route = '/api/'.$pluralUnder;
$entityFqcn = $generator->createClassNameDetails(
@@ -129,9 +146,34 @@ final class BridgeResourceMaker extends AbstractMaker
__DIR__.'/templates/Entity.tpl.php',
$vars,
);
if ($useDto) {
$createDto = $generator->createClassNameDetails(
'Create'.$singular,
'Dto\\',
'Dto',
);
$updateDto = $generator->createClassNameDetails(
'Update'.$singular,
'Dto\\',
'Dto',
);
$generator->generateFile(
'src/Dto/'.$createDto->getShortName().'.php',
__DIR__.'/templates/CreateDto.tpl.php',
$vars,
);
$generator->generateFile(
'src/Dto/'.$updateDto->getShortName().'.php',
__DIR__.'/templates/UpdateDto.tpl.php',
$vars,
);
}
$controllerTemplate = $useDto ? 'ControllerWithDto.tpl.php' : 'Controller.tpl.php';
$generator->generateFile(
'src/Controller/'.$controllerFqcn->getShortName().'.php',
__DIR__.'/templates/Controller.tpl.php',
__DIR__.'/templates/'.$controllerTemplate,
$vars,
);

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace PhpQml\Bridge\Maker;
use PhpQml\Bridge\Maker\Support\NameInput;
use Symfony\Bundle\MakerBundle\ConsoleStyle;
use Symfony\Bundle\MakerBundle\DependencyBuilder;
use Symfony\Bundle\MakerBundle\Generator;
@@ -57,16 +58,13 @@ final class BridgeWindowMaker extends AbstractMaker
public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void
{
if (null === $input->getArgument('name')) {
$name = $io->ask('Window name?', null, static function (?string $v): string {
if (null === $v || '' === trim($v)) {
throw new \RuntimeException('Window name cannot be empty.');
}
return ucfirst(trim($v));
});
$input->setArgument('name', $name);
}
NameInput::askOrFail(
$input,
$io,
'name',
'Window name?',
'Window name cannot be empty.',
);
}
public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace PhpQml\Bridge\Maker\Support;
use Symfony\Bundle\MakerBundle\ConsoleStyle;
use Symfony\Component\Console\Input\InputInterface;
/**
* Shared interactive name prompt for the bridge makers.
*
* Every `make:bridge:*` maker takes a single CamelCase `name` argument
* and re-implemented the same "prompt, trim, ucfirst, reject empty"
* closure inline. This collapses that into one call site so the empty-
* argument and validation behaviour stay in lockstep across makers.
*/
final class NameInput
{
/**
* Fill the named argument from an interactive prompt if it isn't set.
*
* The closure-based validator throws `\RuntimeException` on empty input,
* which Maker-bundle's `ConsoleStyle::ask()` interprets as "render
* error, re-prompt" rather than aborting — same behaviour as the
* inline closures it replaces.
*/
public static function askOrFail(
InputInterface $input,
ConsoleStyle $io,
string $argument,
string $question,
string $errorMessage = 'Name cannot be empty.',
): void {
if (null !== $input->getArgument($argument)) {
return;
}
$value = $io->ask($question, null, static function (?string $v) use ($errorMessage): string {
if (null === $v || '' === trim($v)) {
throw new \RuntimeException($errorMessage);
}
return ucfirst(trim($v));
});
$input->setArgument($argument, $value);
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace PhpQml\Bridge\Maker\Support;
/**
* CamelCase → separator-joined lowercase converter shared between makers.
*
* `BridgeResourceMaker` needs `_` (route plural → table-style); `BridgeCommandMaker`
* needs `-` (kebab route slug). Same regex, two separators — collapsed here so
* the regex lives in one place.
*/
final class Naming
{
/**
* Convert a CamelCase identifier to a separator-joined lowercase string.
*
* camelTo('TodoList', '_') === 'todo_list'
* camelTo('MarkAllDone', '-') === 'mark-all-done'
* camelTo('Todo', '-') === 'todo'
*/
public static function camelTo(string $name, string $separator): string
{
return strtolower(preg_replace('/(?<!^)[A-Z]/', $separator.'$0', $name) ?? $name);
}
}

View File

@@ -0,0 +1,95 @@
<?= "<?php\n" ?>
declare(strict_types=1);
namespace App\Controller;
use App\Dto\Create<?= $entity_short ?>Dto;
use App\Dto\Update<?= $entity_short ?>Dto;
use <?= $entity_fqcn ?>;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
/**
* Auto-generated CRUD controller for the <?= $singular ?> bridge resource (DTO-shaped).
* Edit freely — re-running make:bridge:resource won't overwrite this file.
*
* Validated input via #[MapRequestPayload]: malformed JSON, missing
* required fields, or constraint violations produce RFC 7807
* problem+json automatically (Symfony's RequestPayloadValueResolver).
*/
#[Route('<?= $route ?>')]
final class <?= $entity_short ?>Controller
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly NormalizerInterface $normalizer,
) {
}
#[Route('', name: '<?= $resource ?>_list', methods: ['GET'])]
public function list(): JsonResponse
{
$items = $this->em->getRepository(<?= $entity_short ?>::class)->findAll();
return new JsonResponse($this->normalizer->normalize($items, 'json'));
}
#[Route('', name: '<?= $resource ?>_create', methods: ['POST'])]
public function create(#[MapRequestPayload] Create<?= $entity_short ?>Dto $dto): JsonResponse
{
$entity = new <?= $entity_short ?>();
$entity->setTitle($dto->title);
$entity->setDone($dto->done);
$this->em->persist($entity);
$this->em->flush();
return new JsonResponse(
$this->normalizer->normalize($entity, 'json'),
Response::HTTP_CREATED,
);
}
#[Route('/{id}', name: '<?= $resource ?>_update', methods: ['PATCH'])]
public function update(string $id, #[MapRequestPayload] Update<?= $entity_short ?>Dto $dto): JsonResponse
{
$entity = $this->em->getRepository(<?= $entity_short ?>::class)->find($id);
if (null === $entity) {
return new JsonResponse(
['title' => 'Not Found', 'status' => 404],
Response::HTTP_NOT_FOUND,
['Content-Type' => 'application/problem+json'],
);
}
if (null !== $dto->title) {
$entity->setTitle($dto->title);
}
if (null !== $dto->done) {
$entity->setDone($dto->done);
}
$this->em->flush();
return new JsonResponse($this->normalizer->normalize($entity, 'json'));
}
#[Route('/{id}', name: '<?= $resource ?>_delete', methods: ['DELETE'])]
public function delete(string $id): JsonResponse
{
$entity = $this->em->getRepository(<?= $entity_short ?>::class)->find($id);
if (null === $entity) {
return new JsonResponse(null, Response::HTTP_NO_CONTENT);
}
$this->em->remove($entity);
$this->em->flush();
return new JsonResponse(null, Response::HTTP_NO_CONTENT);
}
}

View File

@@ -0,0 +1,26 @@
<?= "<?php\n" ?>
declare(strict_types=1);
namespace App\Dto;
use Symfony\Component\Validator\Constraints as Assert;
/**
* Validated payload for POST <?= $route ?>.
*
* Auto-generated alongside <?= $entity_short ?>Controller's create() action.
* #[MapRequestPayload] in the controller turns malformed JSON or any
* Assert violation here into an RFC 7807 problem+json response — no
* controller-level if-isset boilerplate, no silent type coercion.
*/
final readonly class Create<?= $entity_short ?>Dto
{
public function __construct(
#[Assert\NotBlank]
#[Assert\Length(max: 255)]
public string $title,
public bool $done = false,
) {
}
}

View File

@@ -0,0 +1,22 @@
<?= "<?php\n" ?>
declare(strict_types=1);
namespace App\Event;
/**
* Domain event published on `app://event/<?= $event_topic ?>` by
* <?= $subscriber_short ?>.
*
* Auto-generated stub — replace the `payload` field with typed
* properties matching the event you actually fire.
*/
final readonly class <?= $event_short ?>
{
public function __construct(
/** @var array<string, mixed> */
public array $payload = [],
) {
}
}

View File

@@ -0,0 +1,31 @@
// Auto-generated by `bin/console make:bridge:event <?= $singular ?>`.
// Listens for `app://event/<?= $event_topic ?>` envelopes published by
// <?= $subscriber_short ?> and re-emits them as a typed QML signal.
//
// Drop into a parent component and connect:
//
// <?= $singular ?>EventHandler {
// on<?= $singular ?>: function(payload) { console.log("hi", payload) }
// }
import QtQuick
import PhpQml.Bridge
Item {
id: handler
/** Emitted when the bridge publishes app://event/<?= $event_topic ?>. */
signal <?= $signal_name ?>(var payload)
MercureClient {
baseUrl: BackendConnection.url
token: BackendConnection.token
topics: ["app://event/<?= $event_topic ?>"]
onUpdate: function(topic, envelope) {
if (topic === "app://event/<?= $event_topic ?>") {
handler.<?= $signal_name ?>(envelope.data)
}
}
}
}

View File

@@ -0,0 +1,39 @@
<?= "<?php\n" ?>
declare(strict_types=1);
namespace App\EventSubscriber;
use App\Event\<?= $event_short ?>;
use PhpQml\Bridge\PublisherInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* Republishes <?= $event_short ?> on `app://event/<?= $event_topic ?>`.
* Auto-generated alongside the event class — wire `payload` to whatever
* shape you want QML clients to receive in the envelope's `data` field.
*/
final readonly class <?= $subscriber_short ?>
implements EventSubscriberInterface
{
public function __construct(
private PublisherInterface $publisher,
) {
}
public static function getSubscribedEvents(): array
{
return [
<?= $event_short ?>::class => '<?= $handler_method ?>',
];
}
public function <?= $handler_method ?>(<?= $event_short ?> $event): void
{
$this->publisher->publish('app://event/<?= $event_topic ?>', [
'op' => 'event',
'data' => $event->payload,
]);
}
}

View File

@@ -0,0 +1,38 @@
<?= "<?php\n" ?>
declare(strict_types=1);
namespace App\ReadModel;
use Doctrine\ORM\EntityManagerInterface;
/**
* Auto-generated query service for the <?= $singular ?> read-model.
*
* Read-models are server-side projections — joined fetches, aggregates,
* denormalised views — that QML reads without going through a writable
* `#[BridgeResource]`. Replace the body of `query()` with the actual
* DQL / raw SQL / joined fetch.
*
* Per PLAN.md §4 *Pagination*, return an array of associative arrays so
* the controller can normalise to JSON without a serializer; or wire up
* a normalizer if you prefer typed DTOs in the projection.
*/
final readonly class <?= $singular ?>ReadModel
{
public function __construct(
private EntityManagerInterface $em,
) {
}
/**
* @return list<array<string, mixed>>
*/
public function query(): array
{
// TODO: implement the read query — DQL, ->createQueryBuilder(),
// or ->getConnection()->executeQuery() as fits.
return [];
}
}

View File

@@ -0,0 +1,29 @@
<?= "<?php\n" ?>
declare(strict_types=1);
namespace App\Controller;
use App\ReadModel\<?= $singular ?>ReadModel;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
/**
* Read-only endpoint for the <?= $singular ?> projection.
* Auto-generated by `make:bridge:read-model` — the read-model owns
* the query; this controller just normalises the result to JSON.
*/
final class <?= $singular ?>Controller
{
public function __construct(
private readonly <?= $singular ?>ReadModel $readModel,
) {
}
#[Route('<?= $route ?>', name: '<?= $resource ?>_read', methods: ['GET'])]
public function __invoke(): JsonResponse
{
return new JsonResponse($this->readModel->query());
}
}

View File

@@ -0,0 +1,28 @@
// Auto-generated by `bin/console make:bridge:read-model <?= $singular ?>`.
// Read-only projection — no Mercure topic, no auto-updates.
//
// For invalidation: when the underlying data changes, dispatch a
// domain event from PHP (see `make:bridge:event`) and call
// `<?= lcfirst($singular) ?>List.refresh()` from the event-handler in QML.
import QtQuick
import QtQuick.Controls
import PhpQml.Bridge
ListView {
id: <?= lcfirst($singular) ?>List
model: ReactiveListModel {
baseUrl: BackendConnection.url
token: BackendConnection.token
source: "<?= $route ?>"
// topic: intentionally unset — read-models are queries, not
// reactive resources. Drive refreshes from domain events.
}
delegate: ItemDelegate {
// Replace with your projection's columns.
text: String(model.modelData)
width: ListView.view.width
}
}

View File

@@ -0,0 +1,24 @@
<?= "<?php\n" ?>
declare(strict_types=1);
namespace App\Dto;
use Symfony\Component\Validator\Constraints as Assert;
/**
* Validated payload for PATCH <?= $route ?>/{id}.
*
* All fields are nullable so PATCH callers can send only the fields
* they want to change. The controller checks each for null and
* skips the corresponding entity setter.
*/
final readonly class Update<?= $entity_short ?>Dto
{
public function __construct(
#[Assert\Length(max: 255)]
public ?string $title = null,
public ?bool $done = null,
) {
}
}

View File

@@ -8,32 +8,35 @@ use PhpQml\Bridge\Attribute\BridgeResource;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
/**
* Translates Doctrine entity lifecycle events into Mercure envelopes.
* Default implementation of {@see ModelPublisherInterface}: dual-publishes
* each change to the collection and entity topics for a `#[BridgeResource]`
* entity.
*
* For each change, dual-publishes to:
* For each change, publishes to:
* - `app://model/{name}` — collection topic, watched by ReactiveListModel
* - `app://model/{name}/{id}` — entity topic, watched by ReactiveObject
*
* Topic / envelope shape per PLAN.md §4. The `correlationKey` echoes
* the originating request's `Idempotency-Key` (§5 *Optimistic updates*).
*
* Phase 2 uses a per-process counter for the envelope `version` field
* — sufficient for single-instance dev mode. Phase 4 / production should
* back this with a persistent monotonic source (e.g. Postgres SEQUENCE).
* Uses a per-process counter for the envelope `version` field — sufficient
* for single-instance bundled mode. Multi-instance / production deployments
* should back this with a persistent monotonic source (e.g. Postgres
* SEQUENCE); deferred to v0.2.0+ §13.
*/
final class ModelPublisher
final class ModelPublisher implements ModelPublisherInterface
{
/** @var array<string, int> */
private array $versions = [];
public function __construct(
private readonly Publisher $publisher,
private readonly CorrelationContext $correlationContext,
private readonly PublisherInterface $publisher,
private readonly CorrelationContextInterface $correlationContext,
private readonly NormalizerInterface $normalizer,
) {
}
public function publishEntityChange(object $entity, string $op): void
public function publishEntityChange(object $entity, BridgeOp $op): void
{
$resource = $this->resolveResource($entity);
if (null === $resource) {
@@ -44,10 +47,10 @@ final class ModelPublisher
$id = (string) $this->extractId($entity);
$envelope = [
'op' => $op,
'op' => $op->value,
'id' => $id,
'version' => $this->nextVersion($name),
'data' => 'delete' === $op ? null : $this->normalize($entity),
'data' => BridgeOp::Delete === $op ? null : $this->normalize($entity),
];
if (null !== $key = $this->correlationContext->get()) {
@@ -77,10 +80,7 @@ final class ModelPublisher
$r = new \ReflectionClass($entity);
if ($r->hasProperty('id')) {
$prop = $r->getProperty('id');
$prop->setAccessible(true);
return $prop->getValue($entity);
return $r->getProperty('id')->getValue($entity);
}
throw new \LogicException(\sprintf('Cannot extract id from %s: no getId() method or $id property.', $entity::class));

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace PhpQml\Bridge;
/**
* Translates a Doctrine entity lifecycle event into the bridge's Mercure
* envelopes (collection topic + entity topic) per PLAN.md §6.
*
* The public API surface of `ModelPublisher`. App code that wants to
* republish a model change manually (rare — the Doctrine listener covers
* the common case) should typehint this interface.
*/
interface ModelPublisherInterface
{
/**
* Publish an `upsert` / `delete` / `replace` envelope for the given
* `#[BridgeResource]` entity. Entities not tagged with the attribute
* are silently ignored.
*/
public function publishEntityChange(object $entity, BridgeOp $op): void;
}

View File

@@ -8,22 +8,22 @@ use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
/**
* Publishes envelopes onto the bridge's Mercure hub.
* Default implementation of {@see PublisherInterface}: a thin wrapper over
* `symfony/mercure`'s `HubInterface` that JSON-encodes the envelope and
* forwards the publish.
*
* Topic conventions and envelope shape are defined in PLAN.md §4.
* Reactive-model-aware helpers (publishModelUpdate, etc.) arrive with
* the model layer in Phase 2.
* Application code should typehint `PublisherInterface` instead of this
* concrete class so swappable implementations (offline buffer, multi-hub
* fan-out) remain a non-breaking change.
*/
final readonly class Publisher
final readonly class Publisher implements PublisherInterface
{
public function __construct(
private HubInterface $hub,
) {
}
/**
* @param array<string, mixed> $envelope
*/
public function publish(string $topic, array $envelope, bool $private = false): string
{
return $this->hub->publish(new Update(

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace PhpQml\Bridge;
/**
* Publishes envelopes onto the bridge's Mercure hub.
*
* The public API surface of `Publisher`. App code should typehint this
* interface — same idiom as upstream `HubInterface` / `EventDispatcherInterface`
* — so the implementation can evolve (e.g. an offline-buffering decorator,
* a multi-hub fan-out) without breaking consumers.
*
* Topic conventions and envelope shape are defined in PLAN.md §4.
*/
interface PublisherInterface
{
/**
* Publish an envelope onto a Mercure topic.
*
* @param array<string, mixed> $envelope shape per PLAN.md §4
* @param bool $private pass-through to Mercure's `private` flag
*
* @return string the Mercure update ID
*/
public function publish(string $topic, array $envelope, bool $private = false): string;
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace PhpQml\Bridge\Tests;
use PhpQml\Bridge\BridgeOp;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
#[CoversClass(BridgeOp::class)]
final class BridgeOpTest extends TestCase
{
/**
* The four cases are the bridge's wire-format envelope `op` tokens
* (PLAN.md §4). QML clients hardcode the strings — renaming an enum
* case is a backwards-compatible PHP-side refactor, but renaming a
* `value` is not. This test fails the build before such a rename
* ships.
*/
public function testWireFormatValuesMatchDocumentedTokens(): void
{
self::assertSame('upsert', BridgeOp::Upsert->value);
self::assertSame('delete', BridgeOp::Delete->value);
self::assertSame('replace', BridgeOp::Replace->value);
self::assertSame('event', BridgeOp::Event->value);
}
}

View File

@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace PhpQml\Bridge\Tests\Command;
use PhpQml\Bridge\Command\BridgeExportCommand;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Tester\CommandTester;
#[CoversClass(BridgeExportCommand::class)]
final class BridgeExportCommandTest extends TestCase
{
private string $tmpDir = '';
protected function setUp(): void
{
$this->tmpDir = sys_get_temp_dir().'/bridge-export-test-'.uniqid();
mkdir($this->tmpDir, 0o700, true);
}
protected function tearDown(): void
{
// Brutal but adequate for a flat test dir.
foreach (glob($this->tmpDir.'/*') ?: [] as $f) {
@unlink($f);
}
@rmdir($this->tmpDir);
}
public function testCopiesSqliteFileToDestination(): void
{
$source = $this->tmpDir.'/source.sqlite';
file_put_contents($source, 'fake-sqlite-bytes');
$tester = new CommandTester(new BridgeExportCommand("sqlite:///{$source}"));
$destination = $this->tmpDir.'/exported.sqlite';
$exitCode = $tester->execute(['destination' => $destination]);
self::assertSame(0, $exitCode);
self::assertFileExists($destination);
self::assertSame('fake-sqlite-bytes', file_get_contents($destination));
self::assertStringContainsString('Exported', $tester->getDisplay());
}
public function testFailsForNonSqliteUrl(): void
{
$tester = new CommandTester(new BridgeExportCommand('postgres://user@host/db'));
$exitCode = $tester->execute(['destination' => $this->tmpDir.'/x.sqlite']);
self::assertSame(1, $exitCode);
self::assertStringContainsString('not a sqlite:/// URL', $tester->getDisplay());
}
public function testFailsWhenSourceMissing(): void
{
$tester = new CommandTester(new BridgeExportCommand("sqlite:///{$this->tmpDir}/no-such.sqlite"));
$exitCode = $tester->execute(['destination' => $this->tmpDir.'/x.sqlite']);
self::assertSame(1, $exitCode);
self::assertStringContainsString('does not exist', $tester->getDisplay());
}
public function testOverwritesExistingDestination(): void
{
$source = $this->tmpDir.'/source.sqlite';
file_put_contents($source, 'new-bytes');
$destination = $this->tmpDir.'/exported.sqlite';
file_put_contents($destination, 'old-bytes');
$tester = new CommandTester(new BridgeExportCommand("sqlite:///{$source}"));
$exitCode = $tester->execute(['destination' => $destination]);
self::assertSame(0, $exitCode);
self::assertSame('new-bytes', file_get_contents($destination));
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace PhpQml\Bridge\Tests\Maker\Support;
use PhpQml\Bridge\Maker\Support\Naming;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
#[CoversClass(Naming::class)]
final class NamingTest extends TestCase
{
#[DataProvider('camelToCases')]
public function testCamelTo(string $input, string $separator, string $expected): void
{
self::assertSame($expected, Naming::camelTo($input, $separator));
}
/** @return iterable<string, array{string, string, string}> */
public static function camelToCases(): iterable
{
yield 'single word, underscore' => ['Todo', '_', 'todo'];
yield 'two words, underscore' => ['TodoList', '_', 'todo_list'];
yield 'two words, dash' => ['MarkAllDone', '-', 'mark-all-done'];
yield 'leading uppercase, no split' => ['Todo', '-', 'todo'];
yield 'all caps stay together (acronym preserved)' => ['HTTPClient', '-', 'h-t-t-p-client'];
yield 'empty input' => ['', '-', ''];
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace PhpQml\Bridge\Tests;
use PhpQml\Bridge\Attribute\BridgeResource;
use PhpQml\Bridge\BridgeOp;
use PhpQml\Bridge\CorrelationContext;
use PhpQml\Bridge\ModelPublisher;
use PhpQml\Bridge\Publisher;
@@ -71,7 +72,7 @@ final class ModelPublisherTest extends TestCase
{
$todo = new FakeTodo(id: '019de596-be1c-7642-985c-edcadeef9b5d', title: 'milk', done: false);
$this->publisher->publishEntityChange($todo, 'upsert');
$this->publisher->publishEntityChange($todo, BridgeOp::Upsert);
// The HubSpy only retains the LAST update. To validate both topics,
// re-publish and check the second envelope, but for the assertion of
@@ -98,7 +99,7 @@ final class ModelPublisherTest extends TestCase
$this->context->set('idem-1234');
$this->publisher->publishEntityChange(
new FakeTodo(id: '1', title: 'x'),
'upsert',
BridgeOp::Upsert,
);
$envelope = json_decode($this->hub->captured->getData(), true);
@@ -109,7 +110,7 @@ final class ModelPublisherTest extends TestCase
{
$this->publisher->publishEntityChange(
new FakeTodo(id: '7', title: 'gone'),
'delete',
BridgeOp::Delete,
);
$envelope = json_decode($this->hub->captured->getData(), true);
@@ -119,17 +120,17 @@ final class ModelPublisherTest extends TestCase
public function testEntitiesWithoutBridgeResourceAreIgnored(): void
{
$this->publisher->publishEntityChange(new FakeNotMarked(1, 'x'), 'upsert');
$this->publisher->publishEntityChange(new FakeNotMarked(1, 'x'), BridgeOp::Upsert);
self::assertNull($this->hub->captured);
}
public function testVersionIncreasesOnEachPublish(): void
{
$this->publisher->publishEntityChange(new FakeTodo(id: '1', title: 'a'), 'upsert');
$this->publisher->publishEntityChange(new FakeTodo(id: '1', title: 'a'), BridgeOp::Upsert);
$first = json_decode($this->hub->captured->getData(), true)['version'];
$this->publisher->publishEntityChange(new FakeTodo(id: '1', title: 'b'), 'upsert');
$this->publisher->publishEntityChange(new FakeTodo(id: '1', title: 'b'), BridgeOp::Upsert);
$second = json_decode($this->hub->captured->getData(), true)['version'];
self::assertGreaterThan($first, $second);

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Dto;
use Symfony\Component\Validator\Constraints as Assert;
/**
* Validated payload for POST /api/todos.
*
* Auto-generated alongside TodoController's create() action.
* #[MapRequestPayload] in the controller turns malformed JSON or any
* Assert violation here into an RFC 7807 problem+json response — no
* controller-level if-isset boilerplate, no silent type coercion.
*/
final readonly class CreateTodoDto
{
public function __construct(
#[Assert\NotBlank]
#[Assert\Length(max: 255)]
public string $title,
public bool $done = false,
) {
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Event;
/**
* Domain event published on `app://event/todo-completed` by
* TodoCompletedSubscriber.
*
* Auto-generated stub — replace the `payload` field with typed
* properties matching the event you actually fire.
*/
final readonly class TodoCompletedEvent
{
public function __construct(
/** @var array<string, mixed> */
public array $payload = [],
) {
}
}

View File

@@ -0,0 +1,31 @@
// Auto-generated by `bin/console make:bridge:event TodoCompleted`.
// Listens for `app://event/todo-completed` envelopes published by
// TodoCompletedSubscriber and re-emits them as a typed QML signal.
//
// Drop into a parent component and connect:
//
// TodoCompletedEventHandler {
// onTodoCompleted: function(payload) { console.log("hi", payload) }
// }
import QtQuick
import PhpQml.Bridge
Item {
id: handler
/** Emitted when the bridge publishes app://event/todo-completed. */
signal todoCompleted(var payload)
MercureClient {
baseUrl: BackendConnection.url
token: BackendConnection.token
topics: ["app://event/todo-completed"]
onUpdate: function(topic, envelope) {
if (topic === "app://event/todo-completed") {
handler.todoCompleted(envelope.data)
}
}
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\EventSubscriber;
use App\Event\TodoCompletedEvent;
use PhpQml\Bridge\PublisherInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* Republishes TodoCompletedEvent on `app://event/todo-completed`.
* Auto-generated alongside the event class — wire `payload` to whatever
* shape you want QML clients to receive in the envelope's `data` field.
*/
final readonly class TodoCompletedSubscriber implements EventSubscriberInterface
{
public function __construct(
private PublisherInterface $publisher,
) {
}
public static function getSubscribedEvents(): array
{
return [
TodoCompletedEvent::class => 'onTodoCompleted',
];
}
public function onTodoCompleted(TodoCompletedEvent $event): void
{
$this->publisher->publish('app://event/todo-completed', [
'op' => 'event',
'data' => $event->payload,
]);
}
}

View File

@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Dto\CreateTodoDto;
use App\Dto\UpdateTodoDto;
use App\Entity\Todo;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
/**
* Auto-generated CRUD controller for the Todo bridge resource (DTO-shaped).
* Edit freely — re-running make:bridge:resource won't overwrite this file.
*
* Validated input via #[MapRequestPayload]: malformed JSON, missing
* required fields, or constraint violations produce RFC 7807
* problem+json automatically (Symfony's RequestPayloadValueResolver).
*/
#[Route('/api/todos')]
final class TodoController
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly NormalizerInterface $normalizer,
) {
}
#[Route('', name: 'todo_list', methods: ['GET'])]
public function list(): JsonResponse
{
$items = $this->em->getRepository(Todo::class)->findAll();
return new JsonResponse($this->normalizer->normalize($items, 'json'));
}
#[Route('', name: 'todo_create', methods: ['POST'])]
public function create(#[MapRequestPayload] CreateTodoDto $dto): JsonResponse
{
$entity = new Todo();
$entity->setTitle($dto->title);
$entity->setDone($dto->done);
$this->em->persist($entity);
$this->em->flush();
return new JsonResponse(
$this->normalizer->normalize($entity, 'json'),
Response::HTTP_CREATED,
);
}
#[Route('/{id}', name: 'todo_update', methods: ['PATCH'])]
public function update(string $id, #[MapRequestPayload] UpdateTodoDto $dto): JsonResponse
{
$entity = $this->em->getRepository(Todo::class)->find($id);
if (null === $entity) {
return new JsonResponse(
['title' => 'Not Found', 'status' => 404],
Response::HTTP_NOT_FOUND,
['Content-Type' => 'application/problem+json'],
);
}
if (null !== $dto->title) {
$entity->setTitle($dto->title);
}
if (null !== $dto->done) {
$entity->setDone($dto->done);
}
$this->em->flush();
return new JsonResponse($this->normalizer->normalize($entity, 'json'));
}
#[Route('/{id}', name: 'todo_delete', methods: ['DELETE'])]
public function delete(string $id): JsonResponse
{
$entity = $this->em->getRepository(Todo::class)->find($id);
if (null === $entity) {
return new JsonResponse(null, Response::HTTP_NO_CONTENT);
}
$this->em->remove($entity);
$this->em->flush();
return new JsonResponse(null, Response::HTTP_NO_CONTENT);
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\ReadModel\TodoSummaryReadModel;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
/**
* Read-only endpoint for the TodoSummary projection.
* Auto-generated by `make:bridge:read-model` — the read-model owns
* the query; this controller just normalises the result to JSON.
*/
final class TodoSummaryController
{
public function __construct(
private readonly TodoSummaryReadModel $readModel,
) {
}
#[Route('/api/todo-summaries', name: 'todo_summary_read', methods: ['GET'])]
public function __invoke(): JsonResponse
{
return new JsonResponse($this->readModel->query());
}
}

View File

@@ -0,0 +1,28 @@
// Auto-generated by `bin/console make:bridge:read-model TodoSummary`.
// Read-only projection — no Mercure topic, no auto-updates.
//
// For invalidation: when the underlying data changes, dispatch a
// domain event from PHP (see `make:bridge:event`) and call
// `todoSummaryList.refresh()` from the event-handler in QML.
import QtQuick
import QtQuick.Controls
import PhpQml.Bridge
ListView {
id: todoSummaryList
model: ReactiveListModel {
baseUrl: BackendConnection.url
token: BackendConnection.token
source: "/api/todo-summaries"
// topic: intentionally unset — read-models are queries, not
// reactive resources. Drive refreshes from domain events.
}
delegate: ItemDelegate {
// Replace with your projection's columns.
text: String(model.modelData)
width: ListView.view.width
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\ReadModel;
use Doctrine\ORM\EntityManagerInterface;
/**
* Auto-generated query service for the TodoSummary read-model.
*
* Read-models are server-side projections — joined fetches, aggregates,
* denormalised views — that QML reads without going through a writable
* `#[BridgeResource]`. Replace the body of `query()` with the actual
* DQL / raw SQL / joined fetch.
*
* Per PLAN.md §4 *Pagination*, return an array of associative arrays so
* the controller can normalise to JSON without a serializer; or wire up
* a normalizer if you prefer typed DTOs in the projection.
*/
final readonly class TodoSummaryReadModel
{
public function __construct(
private EntityManagerInterface $em,
) {
}
/**
* @return list<array<string, mixed>>
*/
public function query(): array
{
// TODO: implement the read query — DQL, ->createQueryBuilder(),
// or ->getConnection()->executeQuery() as fits.
return [];
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Dto;
use Symfony\Component\Validator\Constraints as Assert;
/**
* Validated payload for PATCH /api/todos/{id}.
*
* All fields are nullable so PATCH callers can send only the fields
* they want to change. The controller checks each for null and
* skips the corresponding entity setter.
*/
final readonly class UpdateTodoDto
{
public function __construct(
#[Assert\Length(max: 255)]
public ?string $title = null,
public ?bool $done = null,
) {
}
}

View File

@@ -24,18 +24,6 @@ sed -i "s|\"../../php\"|\"$BUNDLE\"|" "$APP/symfony/composer.json"
rm -f "$APP/symfony/composer.lock"
( cd "$APP/symfony" && composer install --no-interaction --quiet )
# Remove the existing maker outputs so the regenerators don't bail.
rm -f "$APP/symfony/src/Entity/Todo.php"
rm -f "$APP/symfony/src/Controller/TodoController.php"
rm -f "$APP/qml/TodoList.qml"
# Run every maker we cover.
( cd "$APP/symfony" \
&& bin/console make:bridge:resource Todo --no-interaction >/dev/null \
&& bin/console make:bridge:command MarkAllDone --no-interaction >/dev/null \
&& bin/console make:bridge:window Todo --no-interaction >/dev/null )
# Compare each generated file to its snapshot baseline.
fail=0
check() {
local generated="$1"
@@ -49,15 +37,50 @@ check() {
fi
}
check "$APP/symfony/src/Entity/Todo.php" "$SCRIPT_DIR/Todo.php"
check "$APP/symfony/src/Controller/TodoController.php" "$SCRIPT_DIR/TodoController.php"
check "$APP/qml/TodoList.qml" "$SCRIPT_DIR/TodoList.qml"
check "$APP/symfony/src/Controller/MarkAllDoneController.php" "$SCRIPT_DIR/MarkAllDoneController.php"
check "$APP/qml/TodoWindow.qml" "$SCRIPT_DIR/TodoWindow.qml"
clear_outputs() {
rm -f "$APP/symfony/src/Entity/Todo.php"
rm -f "$APP/symfony/src/Controller/TodoController.php"
rm -f "$APP/symfony/src/Dto/CreateTodoDto.php"
rm -f "$APP/symfony/src/Dto/UpdateTodoDto.php"
rm -f "$APP/qml/TodoList.qml"
}
# ── Mode 1: legacy (no --with-dto) ────────────────────────────────────
clear_outputs
( cd "$APP/symfony" \
&& bin/console make:bridge:resource Todo --no-interaction >/dev/null \
&& bin/console make:bridge:command MarkAllDone --no-interaction >/dev/null \
&& bin/console make:bridge:window Todo --no-interaction >/dev/null \
&& bin/console make:bridge:event TodoCompleted --no-interaction >/dev/null \
&& bin/console make:bridge:read-model TodoSummary --no-interaction >/dev/null )
check "$APP/symfony/src/Entity/Todo.php" "$SCRIPT_DIR/Todo.php"
check "$APP/symfony/src/Controller/TodoController.php" "$SCRIPT_DIR/TodoController.php"
check "$APP/qml/TodoList.qml" "$SCRIPT_DIR/TodoList.qml"
check "$APP/symfony/src/Controller/MarkAllDoneController.php" "$SCRIPT_DIR/MarkAllDoneController.php"
check "$APP/qml/TodoWindow.qml" "$SCRIPT_DIR/TodoWindow.qml"
check "$APP/symfony/src/Event/TodoCompletedEvent.php" "$SCRIPT_DIR/TodoCompletedEvent.php"
check "$APP/symfony/src/EventSubscriber/TodoCompletedSubscriber.php" "$SCRIPT_DIR/TodoCompletedSubscriber.php"
check "$APP/qml/TodoCompletedEventHandler.qml" "$SCRIPT_DIR/TodoCompletedEventHandler.qml"
check "$APP/symfony/src/ReadModel/TodoSummaryReadModel.php" "$SCRIPT_DIR/TodoSummaryReadModel.php"
check "$APP/symfony/src/Controller/TodoSummaryController.php" "$SCRIPT_DIR/TodoSummaryController.php"
check "$APP/qml/TodoSummaryList.qml" "$SCRIPT_DIR/TodoSummaryList.qml"
# ── Mode 2: --with-dto (re-runs make:bridge:resource only) ────────────
# The entity + QML output is byte-identical between modes; only the
# controller swaps and the two DTOs appear. Re-checking the unchanged
# outputs would just be noise.
clear_outputs
( cd "$APP/symfony" \
&& bin/console make:bridge:resource Todo --with-dto --no-interaction >/dev/null )
check "$APP/symfony/src/Controller/TodoController.php" "$SCRIPT_DIR/TodoControllerWithDto.php"
check "$APP/symfony/src/Dto/CreateTodoDto.php" "$SCRIPT_DIR/CreateTodoDto.php"
check "$APP/symfony/src/Dto/UpdateTodoDto.php" "$SCRIPT_DIR/UpdateTodoDto.php"
if [ "$fail" -ne 0 ]; then
echo "Snapshot test failed. If the change is intended, update the baselines under $SCRIPT_DIR/." >&2
exit 1
fi
echo "All maker outputs match snapshots."
echo "All maker outputs match snapshots (legacy + --with-dto modes)."

View File

@@ -52,3 +52,12 @@ target_link_libraries(php_qml_bridge PUBLIC
Qt6::Qml
Qt6::Quick
)
# QML unit tests — opt-in. Only built when configuring with
# -DBUILD_TESTING=ON or invoking ctest as part of a top-level project
# that enable_testing()'d. Skipped by the skeleton + example app
# release builds so production AppImages don't carry the test exe.
if(BUILD_TESTING AND CMAKE_PROJECT_NAME STREQUAL PROJECT_NAME)
enable_testing()
add_subdirectory(tests)
endif()

View File

@@ -1,8 +1,10 @@
#include "BackendConnection.h"
#include <QCoreApplication>
#include <QDateTime>
#include <QDebug>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QLoggingCategory>
#include <QNetworkAccessManager>
@@ -13,6 +15,8 @@
#include <QRandomGenerator>
#include <QSocketNotifier>
#include <QStandardPaths>
#include <QTcpServer>
#include <QTextStream>
#include <QTimer>
#include <QUrl>
@@ -182,7 +186,24 @@ void BackendConnection::initBundledMode()
setToken(randomSecret(32));
m_jwtSecret = randomSecret(48); // ≥256 bits for lcobucci/jwt
// Pick a free port instead of hardcoded 8765 — two installed
// php-qml apps used to collide on first launch (whichever lost
// the race went Offline). PLAN.md §13 v0.2.0 *Bundled-mode port
// negotiation*. BRIDGE_PORT env var overrides for tests / dev.
if (qEnvironmentVariableIsSet("BRIDGE_PORT")) {
bool ok = false;
const int forced = qgetenv("BRIDGE_PORT").toInt(&ok);
if (ok && forced > 0 && forced < 65536) {
m_port = static_cast<quint16>(forced);
qCInfo(lcBundled) << "BRIDGE_PORT override: using port" << m_port;
}
} else {
m_port = pickFreePort();
qCInfo(lcBundled) << "negotiated port:" << m_port;
}
setUrl(QStringLiteral("http://127.0.0.1:%1").arg(m_port));
writePortSentinel();
if (!runMigrations()) {
return; // setError already invoked
@@ -262,8 +283,84 @@ QString BackendConnection::databaseUrl() const
return QStringLiteral("sqlite:///%1/var/data.sqlite").arg(m_dataDir);
}
void BackendConnection::backupDatabase()
{
// Cheap insurance against a bad migration corrupting the user's
// data. Doctrine offers no rollback for a half-applied migration
// against SQLite (no transactional DDL), so the only safe undo is
// restoring a copy. PLAN.md §12 *Migrations on schema change*.
const QString sqlitePath = m_dataDir + QStringLiteral("/var/data.sqlite");
if (!QFileInfo::exists(sqlitePath)) {
return; // first launch — nothing to back up yet.
}
const QString varDir = m_dataDir + QStringLiteral("/var");
const qint64 stamp = QDateTime::currentSecsSinceEpoch();
const QString backupPath = QStringLiteral("%1/data.sqlite.%2.bak").arg(varDir).arg(stamp);
if (!QFile::copy(sqlitePath, backupPath)) {
// Don't fail the launch on a backup miss — log and continue.
// The user has a working DB; a missing safety-net is not a
// reason to refuse to boot the app.
qCWarning(lcBundled) << "pre-migration backup failed (continuing without):"
<< "could not copy" << sqlitePath << "" << backupPath;
return;
}
qCInfo(lcBundled) << "pre-migration backup written to" << backupPath;
// Trim to N most recent. QDir::Time sort is mtime descending.
const QStringList existing = QDir(varDir)
.entryList(QStringList{QStringLiteral("data.sqlite.*.bak")},
QDir::Files, QDir::Time);
for (int i = kMaxDatabaseBackups; i < existing.size(); ++i) {
const QString stale = varDir + QLatin1Char('/') + existing.at(i);
if (!QFile::remove(stale)) {
qCWarning(lcBundled) << "could not trim stale backup" << stale;
}
}
}
quint16 BackendConnection::pickFreePort() const
{
// Bind to port 0 → kernel allocates an ephemeral port → close →
// the port stays available for frankenphp's bind a few ms later.
// There is a TOCTOU window (another process could grab the port
// in between), but on Linux the kernel does not eagerly reassign
// recently-released ephemeral ports and frankenphp's bind happens
// synchronously inside spawnChild. If we lose the race, the user
// sees a "spawning frankenphp on port N" log followed by a bind
// failure — fail loud rather than silently retrying with a
// potentially-also-collided port.
QTcpServer probe;
if (!probe.listen(QHostAddress::LocalHost, 0)) {
qCWarning(lcBundled) << "could not bind a free port; falling back to default 8765";
return 8765;
}
const quint16 port = probe.serverPort();
probe.close();
return port;
}
void BackendConnection::writePortSentinel() const
{
// Write the chosen port to var/bridge.port so test harnesses
// (bundled-supervisor.sh, perfsmoke.sh) can discover it without
// needing to parse Qt's log output. Same data dir the supervisor
// uses for cache + sqlite, so XDG_DATA_HOME isolation in tests
// keeps each run's sentinel separate.
const QString path = m_dataDir + QStringLiteral("/var/bridge.port");
QFile f(path);
if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate | QIODevice::Text)) {
qCWarning(lcBundled) << "could not write port sentinel" << path << ":" << f.errorString();
return;
}
QTextStream(&f) << m_port << '\n';
}
bool BackendConnection::runMigrations()
{
backupDatabase();
QProcess proc;
proc.setProgram(resolveFrankenphpBin());
proc.setArguments({
@@ -431,6 +528,46 @@ QStringList BackendConnection::childLogTail() const
return out;
}
bool BackendConnection::exportDatabase(const QString& destination)
{
if (m_mode != Mode::Bundled) {
emit databaseExportFailed(QStringLiteral("dev mode: copy var/data.sqlite directly"));
return false;
}
const QString src = m_dataDir + QStringLiteral("/var/data.sqlite");
if (!QFileInfo::exists(src)) {
const QString msg = QStringLiteral("source not found: %1 (no data yet?)").arg(src);
emit databaseExportFailed(msg);
return false;
}
// QML's FileDialog returns `file://` URLs; unwrap to a local path.
QString dst = destination;
if (dst.startsWith(QStringLiteral("file://"))) {
dst = QUrl(destination).toLocalFile();
}
if (dst.isEmpty()) {
emit databaseExportFailed(QStringLiteral("destination path is empty"));
return false;
}
// QFile::copy refuses to overwrite. The FileDialog the user picked
// through has already confirmed any overwrite, so unlink first.
if (QFileInfo::exists(dst) && !QFile::remove(dst)) {
emit databaseExportFailed(QStringLiteral("could not remove existing destination: ") + dst);
return false;
}
if (!QFile::copy(src, dst)) {
emit databaseExportFailed(QStringLiteral("copy failed: ") + src + QStringLiteral("") + dst);
return false;
}
qCInfo(lcBundled) << "exportDatabase:" << src << "" << dst;
emit databaseExported(dst);
return true;
}
void BackendConnection::onChildFinished(int exitCode, QProcess::ExitStatus status)
{
Q_UNUSED(status);
@@ -534,6 +671,45 @@ void BackendConnection::setState(ConnectionState s)
if (m_state == s) return;
m_state = s;
emit connectionStateChanged();
if (s == ConnectionState::Online) {
armAutoUpdateOnFirstOnline();
}
}
void BackendConnection::armAutoUpdateOnFirstOnline()
{
if (m_autoUpdateArmed) return;
if (m_mode != Mode::Bundled) return;
if (qEnvironmentVariableIsSet("BRIDGE_AUTO_UPDATE_DISABLE")
&& qgetenv("BRIDGE_AUTO_UPDATE_DISABLE") == "1") {
qCInfo(lcBundled) << "auto-update disabled via BRIDGE_AUTO_UPDATE_DISABLE";
m_autoUpdateArmed = true;
return;
}
m_autoUpdateArmed = true;
int periodMs = kAutoUpdateDefaultPeriodMs;
bool ok = false;
const int overrideMin = qgetenv("BRIDGE_AUTO_UPDATE_PERIOD_MIN").toInt(&ok);
if (ok && overrideMin > 0) {
periodMs = overrideMin * 60 * 1000;
}
// First check: a few seconds after first Online so a fresh launch
// doesn't fight for bandwidth/CPU with the cold boot. Subsequent
// checks: every periodMs (default 6h).
QTimer::singleShot(kAutoUpdateLaunchDelayMs, this, &BackendConnection::checkForUpdates);
if (!m_autoUpdateTimer) {
m_autoUpdateTimer = new QTimer(this);
m_autoUpdateTimer->setSingleShot(false);
connect(m_autoUpdateTimer, &QTimer::timeout,
this, &BackendConnection::checkForUpdates);
}
m_autoUpdateTimer->start(periodMs);
qCInfo(lcBundled) << "auto-update armed: launch check in"
<< kAutoUpdateLaunchDelayMs << "ms, period"
<< (periodMs / 60000) << "min";
}
void BackendConnection::setError(const QString& msg)

View File

@@ -85,6 +85,15 @@ public:
/// mode. Used by `DevConsole.qml` to seed its view on first show.
Q_INVOKABLE QStringList childLogTail() const;
/// Bundled mode: copy `var/data.sqlite` to a user-chosen path
/// (typically a `Qt.labs.platform.FileDialog` result). Returns
/// true on success; on failure emits `databaseExportFailed` with
/// a human-readable reason. Use for an "Export my data" menu
/// item paired with the `bridge:export` console command for
/// CLI parity. Dev mode: returns false (developers own their
/// var/data.sqlite lifecycle directly).
Q_INVOKABLE bool exportDatabase(const QString& destination);
signals:
void urlChanged();
void tokenChanged();
@@ -101,6 +110,9 @@ signals:
void updateApplied();
void updateApplyFailed(const QString& reason);
void databaseExported(const QString& destination);
void databaseExportFailed(const QString& reason);
/// Emitted for each newline-terminated chunk read from the bundled
/// FrankenPHP child's merged stdout+stderr stream. DevConsole.qml
/// listens for these to populate its log view live.
@@ -118,6 +130,9 @@ private:
void initDevMode();
void initBundledMode();
bool runMigrations();
void backupDatabase();
quint16 pickFreePort() const;
void writePortSentinel() const;
bool spawnChild(QString* errorOut = nullptr);
void teardownChild();
QString resolveFrankenphpBin() const;
@@ -154,10 +169,25 @@ private:
QQueue<QString> m_childLog;
QString m_childLogBuffer;
static constexpr int kChildLogMax = 500;
/// Pre-migration auto-backup keeps the N most recent .bak files so
/// the user can roll back a bad migration without losing earlier
/// safety nets. PLAN.md §12 *Migrations on schema change*.
static constexpr int kMaxDatabaseBackups = 5;
QProcess* m_updateCheck = nullptr;
QProcess* m_updateApply = nullptr;
/// Periodic AppImageUpdate poll. Armed on first Online transition
/// in bundled mode; PLAN.md §11 *Auto-update*. Disabled by env
/// `BRIDGE_AUTO_UPDATE_DISABLE=1`; period override via
/// `BRIDGE_AUTO_UPDATE_PERIOD_MIN=<minutes>`.
QTimer* m_autoUpdateTimer = nullptr;
bool m_autoUpdateArmed = false;
static constexpr int kAutoUpdateLaunchDelayMs = 10'000; // 10s after first Online
static constexpr int kAutoUpdateDefaultPeriodMs = 6 * 60 * 60 * 1000; // 6h
void armAutoUpdateOnFirstOnline();
QString resolveSidecarUpdater() const;
QString currentAppImagePath() const;
};

View File

@@ -28,13 +28,21 @@ ReactiveListModel::~ReactiveListModel()
qDeleteAll(m_echoTimers);
}
void ReactiveListModel::componentComplete()
{
m_complete = true;
if (!m_baseUrl.isEmpty() && !m_source.isEmpty()) {
refresh();
}
}
void ReactiveListModel::setBaseUrl(const QString& v)
{
if (m_baseUrl == v) return;
m_baseUrl = v;
rewireMercure();
emit baseUrlChanged();
if (!m_source.isEmpty()) refresh();
if (m_complete && !m_source.isEmpty()) refresh();
}
void ReactiveListModel::setToken(const QString& v)
@@ -50,7 +58,7 @@ void ReactiveListModel::setSource(const QString& v)
if (m_source == v) return;
m_source = v;
emit sourceChanged();
if (!m_baseUrl.isEmpty()) refresh();
if (m_complete && !m_baseUrl.isEmpty()) refresh();
}
void ReactiveListModel::setTopic(const QString& v)

View File

@@ -4,6 +4,7 @@
#include <QHash>
#include <QJsonArray>
#include <QJsonObject>
#include <QQmlParserStatus>
#include <QString>
#include <QVector>
#include <QtQmlIntegration>
@@ -27,9 +28,10 @@ class MercureClient;
/// version-gap detection. Cursor pagination is wired but the default
/// "fetch everything" behaviour is fine for small collections; bigger
/// resources should set `pageSize` and call `fetchMore()` from the view.
class ReactiveListModel : public QAbstractListModel
class ReactiveListModel : public QAbstractListModel, public QQmlParserStatus
{
Q_OBJECT
Q_INTERFACES(QQmlParserStatus)
QML_ELEMENT
Q_PROPERTY(QString baseUrl READ baseUrl WRITE setBaseUrl NOTIFY baseUrlChanged)
@@ -43,6 +45,16 @@ public:
explicit ReactiveListModel(QObject* parent = nullptr);
~ReactiveListModel() override;
// QQmlParserStatus — lets us defer the initial fetch until ALL
// bindings have landed. Without this, a setter that sees enough
// state to fetch (baseUrl + source) can fire `refresh()` before
// the binding for `token` has run, sending an unauthenticated GET
// and parking an empty model. componentComplete() is the single
// safe trigger for the first fetch; later setter changes still
// fire refresh() inline as before.
void classBegin() override {}
void componentComplete() override;
// QAbstractListModel
int rowCount(const QModelIndex& parent = QModelIndex()) const override;
QVariant data(const QModelIndex& index, int role) const override;
@@ -121,6 +133,7 @@ private:
QString m_source;
QString m_topic;
bool m_ready = false;
bool m_complete = false; // QQmlParserStatus marker
QString m_error;
QNetworkAccessManager* m_nam = nullptr;

View File

@@ -29,13 +29,21 @@ ReactiveObject::~ReactiveObject()
qDeleteAll(m_echoTimers);
}
void ReactiveObject::componentComplete()
{
m_complete = true;
if (!m_baseUrl.isEmpty() && !m_source.isEmpty()) {
refresh();
}
}
void ReactiveObject::setBaseUrl(const QString& v)
{
if (m_baseUrl == v) return;
m_baseUrl = v;
rewireMercure();
emit baseUrlChanged();
if (!m_source.isEmpty()) refresh();
if (m_complete && !m_source.isEmpty()) refresh();
}
void ReactiveObject::setToken(const QString& v)
@@ -50,7 +58,7 @@ void ReactiveObject::setSource(const QString& v)
if (m_source == v) return;
m_source = v;
emit sourceChanged();
if (!m_baseUrl.isEmpty()) refresh();
if (m_complete && !m_baseUrl.isEmpty()) refresh();
}
void ReactiveObject::setTopic(const QString& v)

View File

@@ -3,6 +3,7 @@
#include <QHash>
#include <QJsonObject>
#include <QObject>
#include <QQmlParserStatus>
#include <QQmlPropertyMap>
#include <QString>
#include <QtQmlIntegration>
@@ -26,9 +27,10 @@ class MercureClient;
/// ReactiveListModel.invoke(): apply locally + Idempotency-Key + roll
/// back on `4xx`/`5xx`/timeout, clear `pending` on the matching
/// Mercure echo (PLAN.md §5).
class ReactiveObject : public QObject
class ReactiveObject : public QObject, public QQmlParserStatus
{
Q_OBJECT
Q_INTERFACES(QQmlParserStatus)
QML_ELEMENT
Q_PROPERTY(QString baseUrl READ baseUrl WRITE setBaseUrl NOTIFY baseUrlChanged)
@@ -45,6 +47,13 @@ public:
explicit ReactiveObject(QObject* parent = nullptr);
~ReactiveObject() override;
// QQmlParserStatus — defer the initial fetch to componentComplete()
// so the GET goes out with token + baseUrl + source all populated,
// regardless of which order QML evaluated the bindings. See the
// matching note on ReactiveListModel.
void classBegin() override {}
void componentComplete() override;
QString baseUrl() const { return m_baseUrl; }
void setBaseUrl(const QString& v);
@@ -110,9 +119,10 @@ private:
QString m_token;
QString m_source;
QString m_topic;
bool m_ready = false;
bool m_pending = false;
bool m_exists = false;
bool m_ready = false;
bool m_pending = false;
bool m_exists = false;
bool m_complete = false; // QQmlParserStatus marker
QString m_error;
QQmlPropertyMap* m_data = nullptr;

View File

@@ -0,0 +1,50 @@
# QML unit tests — opt-in, only built when BUILD_TESTING is on (CTest's
# convention). Wired from ../CMakeLists.txt under the same guard.
#
# Run via:
# cmake -S . -B build -DBUILD_TESTING=ON
# cmake --build build --target qml_unit_tests
# ctest --test-dir build --output-on-failure -R qml_unit_tests
#
# Or from the skeleton / example Makefiles via `make qmltest`.
find_package(Qt6 6.5 REQUIRED COMPONENTS QuickTest Network)
# A tiny PhpQml.Bridge.Tests QML module that exposes the in-process
# stub HTTP server used by tst_reactive_list_model.qml. Static so it
# links into the test exe alongside the production bridge module.
qt_add_qml_module(php_qml_bridge_tests
URI PhpQml.Bridge.Tests
VERSION 1.0
STATIC
OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/PhpQml/Bridge/Tests
SOURCES
TestHttpServer.h
TestHttpServer.cpp
)
target_link_libraries(php_qml_bridge_tests PUBLIC
Qt6::Core
Qt6::Network
Qt6::Qml
)
qt_add_executable(qml_unit_tests main.cpp)
target_link_libraries(qml_unit_tests PRIVATE
Qt6::QuickTest
Qt6::Qml
Qt6::Quick
php_qml_bridge # production module — type implementations
php_qml_bridgeplugin # …and its auto-generated QQmlEngineExtensionPlugin
php_qml_bridge_tests # in-process HTTP stub
php_qml_bridge_testsplugin # …and its plugin
)
# QUICK_TEST_MAIN reads QUICK_TEST_SOURCE_DIR from the macro definition
# at compile time. Point it at this directory so qmltestrunner finds
# the tst_*.qml files regardless of where the binary runs.
target_compile_definitions(qml_unit_tests PRIVATE
QUICK_TEST_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}"
)
add_test(NAME qml_unit_tests COMMAND qml_unit_tests)

View File

@@ -0,0 +1,115 @@
#include "TestHttpServer.h"
#include <QHostAddress>
#include <QTcpSocket>
namespace PhpQml::Bridge::Tests {
TestHttpServer::TestHttpServer(QObject* parent)
: QObject(parent)
{
m_server.listen(QHostAddress::LocalHost, 0);
connect(&m_server, &QTcpServer::newConnection,
this, &TestHttpServer::onNewConnection);
}
QString TestHttpServer::url() const
{
return QStringLiteral("http://127.0.0.1:%1").arg(m_server.serverPort());
}
void TestHttpServer::setResponseBody(const QString& v)
{
if (m_responseBody == v) return;
m_responseBody = v;
emit responseBodyChanged();
}
void TestHttpServer::setResponseStatus(int v)
{
if (m_responseStatus == v) return;
m_responseStatus = v;
emit responseStatusChanged();
}
void TestHttpServer::onNewConnection()
{
while (auto* sock = m_server.nextPendingConnection()) {
// One buffer per socket, owned by the socket so it dies with it.
// (The original thread_local trick leaked between connections.)
auto* buffer = new QByteArray;
connect(sock, &QObject::destroyed, [buffer]() { delete buffer; });
connect(sock, &QTcpSocket::readyRead, this, [this, sock, buffer]() {
buffer->append(sock->readAll());
const int headerEnd = buffer->indexOf("\r\n\r\n");
if (headerEnd < 0) return;
const QByteArray headerBlock = buffer->left(headerEnd);
buffer->clear();
const QList<QByteArray> lines = headerBlock.split('\n');
QString requestLine;
QString authHeader;
for (int i = 0; i < lines.size(); ++i) {
QByteArray line = lines[i];
if (line.endsWith('\r')) line.chop(1);
if (i == 0) {
requestLine = QString::fromUtf8(line);
continue;
}
const int colon = line.indexOf(':');
if (colon < 0) continue;
const QByteArray name = line.left(colon).trimmed();
const QByteArray value = line.mid(colon + 1).trimmed();
if (name.compare("Authorization", Qt::CaseInsensitive) == 0) {
authHeader = QString::fromUtf8(value);
}
}
// Only count + capture metrics for /api/… GETs. SSE reconnect
// attempts from MercureClient hit /.well-known/mercure on the
// same port and would otherwise inflate the request count and
// overwrite the captured headers we want to assert against.
const bool isApiGet = requestLine.startsWith(QStringLiteral("GET /api/"));
if (isApiGet) {
if (m_lastRequestLine != requestLine) {
m_lastRequestLine = requestLine;
emit lastRequestLineChanged();
}
if (m_lastAuthHeader != authHeader) {
m_lastAuthHeader = authHeader;
emit lastAuthHeaderChanged();
}
++m_apiGetCount;
emit apiGetCountChanged();
}
// For /api/ routes, mimic SessionAuthenticator and reject
// requests without an Authorization header. This is what
// exposes the property-order race in the regression test:
// pre-fix, the GET went out unauthenticated, this server
// returned 401, and the model parked with `ready === false`.
const bool needAuth = isApiGet;
const bool isAuthed = !authHeader.isEmpty();
const bool reject = needAuth && !isAuthed;
const int status = reject ? 401 : m_responseStatus;
const QByteArray body = reject
? QByteArrayLiteral(R"({"type":"about:blank","title":"Unauthorized","status":401})")
: m_responseBody.toUtf8();
QByteArray resp;
resp.append("HTTP/1.1 ").append(QByteArray::number(status))
.append(' ').append(status == 200 ? "OK" : "STATUS").append("\r\n");
resp.append("Content-Type: application/json\r\n");
resp.append("Content-Length: ").append(QByteArray::number(body.size())).append("\r\n");
resp.append("Connection: close\r\n\r\n");
resp.append(body);
sock->write(resp);
sock->disconnectFromHost();
});
connect(sock, &QTcpSocket::disconnected, sock, &QObject::deleteLater);
}
}
} // namespace PhpQml::Bridge::Tests

View File

@@ -0,0 +1,76 @@
// Tiny localhost HTTP server for qmltest fixtures. Listens on a free
// ephemeral port; for any incoming request, captures the request line +
// headers and replies with a fixed JSON body. Exposed to QML as the
// `TestHttpServer` element so tests can instantiate one inline:
//
// TestHttpServer {
// id: srv
// responseBody: '[{"id":"1","title":"a","done":false}]'
// }
// ReactiveListModel { baseUrl: srv.url; ... }
// compare(srv.lastAuthHeader, "Bearer testtoken")
//
// Just enough HTTP to serve a single line-of-sight request — no
// chunked encoding, no keepalive, no Content-Length parsing on the
// way in. The framework's network paths only ever issue GET /…
// against this stub during the test, so that's all we need.
//
// `apiGetCount` counts only requests under `/api/…` so tests can
// distinguish the model's HTTP fetches from Mercure's SSE reconnect
// attempts (which hit `/.well-known/mercure`).
#pragma once
#include <QObject>
#include <QTcpServer>
#include <QtQmlIntegration>
namespace PhpQml::Bridge::Tests {
class TestHttpServer : public QObject
{
Q_OBJECT
QML_ELEMENT
Q_PROPERTY(int port READ port CONSTANT)
Q_PROPERTY(QString url READ url CONSTANT)
Q_PROPERTY(QString responseBody READ responseBody WRITE setResponseBody NOTIFY responseBodyChanged)
Q_PROPERTY(int responseStatus READ responseStatus WRITE setResponseStatus NOTIFY responseStatusChanged)
Q_PROPERTY(int apiGetCount READ apiGetCount NOTIFY apiGetCountChanged)
Q_PROPERTY(QString lastAuthHeader READ lastAuthHeader NOTIFY lastAuthHeaderChanged)
Q_PROPERTY(QString lastRequestLine READ lastRequestLine NOTIFY lastRequestLineChanged)
public:
explicit TestHttpServer(QObject* parent = nullptr);
int port() const { return m_server.serverPort(); }
QString url() const;
QString responseBody() const { return m_responseBody; }
int responseStatus() const { return m_responseStatus; }
int apiGetCount() const { return m_apiGetCount; }
QString lastAuthHeader() const { return m_lastAuthHeader; }
QString lastRequestLine() const { return m_lastRequestLine; }
void setResponseBody(const QString& v);
void setResponseStatus(int v);
signals:
void responseBodyChanged();
void responseStatusChanged();
void apiGetCountChanged();
void lastAuthHeaderChanged();
void lastRequestLineChanged();
private slots:
void onNewConnection();
private:
QTcpServer m_server;
QString m_responseBody = QStringLiteral("[]");
int m_responseStatus = 200;
int m_apiGetCount = 0;
QString m_lastAuthHeader;
QString m_lastRequestLine;
};
} // namespace PhpQml::Bridge::Tests

View File

@@ -0,0 +1,21 @@
// QML unit-test runner. Bootstraps the Qt Quick Test framework against
// the `tst_*.qml` files in this directory. Invoked via CMake's
// `qmltest` test target (CTest) or directly via the produced exe.
//
// PLAN.md §13 v0.2.0 testing-strategy row.
#include <QtPlugin>
#include <QtQuickTest/quicktest.h>
// Static QML modules need their auto-generated plugin classes pulled
// in explicitly — the linker would otherwise strip the registration
// init code because nothing in main() references it. Without these
// imports the QmlEngine that QUICK_TEST_MAIN spins up can't resolve
// `import PhpQml.Bridge` / `import PhpQml.Bridge.Tests`.
//
// Plugin class names are auto-generated by qt_add_qml_module(STATIC)
// from the URI: dots become underscores, suffixed with "Plugin".
Q_IMPORT_PLUGIN(PhpQml_BridgePlugin)
Q_IMPORT_PLUGIN(PhpQml_Bridge_TestsPlugin)
QUICK_TEST_MAIN(qml_unit_tests)

View File

@@ -0,0 +1,118 @@
// Regression test for the property-assignment-order bug that left a
// second window's ReactiveListModel empty on first open. Both
// reproductions cover the same root cause: setBaseUrl() / setSource()
// used to fire `refresh()` inline, which meant whichever setter
// happened to land *last* triggered the GET — and that GET captured
// whatever m_token was at that exact instant. setToken() never fires
// refresh() itself, so if QML evaluated `token` after `baseUrl` /
// `source`, the first GET went out unauthenticated and the model
// parked an empty list.
//
// The fix defers the initial fetch to QQmlParserStatus::componentComplete().
// By that point every binding (literal *and* singleton-derived) has
// landed, so refresh() picks up the bearer.
import QtQuick
import QtTest
import PhpQml.Bridge
import PhpQml.Bridge.Tests
TestCase {
name: "ReactiveListModel"
when: windowShown
// ── Stub backend ────────────────────────────────────────────────
TestHttpServer {
id: srv
responseBody: '[{"id":"1","title":"hello","done":false}]'
}
// Stand-in for the BackendConnection singleton — exposes the same
// shape (`url`, `token` properties) so the model's bindings depend
// on a third object the same way the production code does. This is
// what reproduces the property-evaluation-order race: when both
// `baseUrl` and `token` are bindings (rather than literals), QML
// evaluates them together in the binding-evaluation phase, *after*
// the literal `source` has been assigned. Pre-fix, the binding
// for `baseUrl` fires `refresh()` inline and the request goes out
// before the binding for `token` has run.
QtObject {
id: backend
property string url: srv.url
property string token: "testtoken"
}
// ── Reproduction A: declarative model with bindings up-front ────
// This is the exact shape examples/todo/qml/TodoWindow.qml uses
// for the second window. Without the fix, the setter that lands
// *second* of {baseUrl, source} fires `refresh()` inline — and
// because QML evaluates literal values before bindings to other
// objects' properties, that setter typically lands before `token`.
// The GET goes out unauthenticated, the test server returns 401,
// and the model parks with `ready === false`. The fix defers the
// initial fetch to componentComplete() so the bearer is always in
// place by the time the request fires.
Component {
id: declarativeModel
ReactiveListModel {
// Same shape as examples/todo/qml/TodoWindow.qml — literals
// for source/topic, bindings to a stand-in BackendConnection
// for baseUrl/token. Without the fix the GET fires before
// `token` lands and the test server's auth check rejects
// it; the model parks at ready === false.
source: "/api/todos"
topic: "app://model/todo"
baseUrl: backend.url
token: backend.token
}
}
function test_declarative_creation_sends_token_on_first_get() {
const baseline = srv.apiGetCount
const m = declarativeModel.createObject(null)
verify(m, "model instance was created")
// Wait for the GET to land. With the fix, the request fires
// exactly once after componentComplete with the bearer set.
tryCompare(srv, "apiGetCount", baseline + 1, 2000,
"ReactiveListModel issued exactly one /api/ GET")
compare(srv.lastAuthHeader, "Bearer testtoken",
"first GET carries the Authorization header — without the fix this is empty")
compare(srv.lastRequestLine, "GET /api/todos HTTP/1.1",
"request line addresses the configured source path")
tryCompare(m, "ready", true, 2000)
m.destroy()
}
// ── Reproduction B: post-componentComplete imperative changes ───
// Once the component is complete, individual setter changes still
// need to trigger refresh inline. This case verifies the fix
// doesn't accidentally suppress refresh forever — only during the
// initial property-assignment pass.
Component {
id: bareModel
ReactiveListModel {}
}
function test_imperative_property_set_after_completion() {
// Each test reuses the same TestHttpServer instance; check the
// delta from a snapshot taken now.
const baseline = srv.apiGetCount
const m = bareModel.createObject(null)
verify(m)
m.token = "imperativeToken"
m.topic = "app://model/todo"
m.source = "/api/todos"
m.baseUrl = srv.url // last of the {baseUrl, source} pair → triggers fetch
tryCompare(srv, "apiGetCount", baseline + 1, 2000)
compare(srv.lastAuthHeader, "Bearer imperativeToken",
"imperative setBaseUrl after token is set fetches with token")
m.destroy()
}
}

View File

@@ -0,0 +1,25 @@
// Smoke test — proves the qmltestrunner harness is wired up. Doesn't
// touch BackendConnection (which would require a live FrankenPHP child)
// or any other framework-side code that needs network/state. The
// assertion is intentionally trivial; the *infrastructure* is what's
// being tested at this layer.
//
// Add domain-meaningful tests as `tst_<feature>.qml` next to this file
// — qmltestrunner auto-discovers any `tst_*.qml` and runs every
// `TestCase` function whose name starts with `test_`.
import QtQuick
import QtTest
TestCase {
name: "Smoke"
function test_qml_engine_alive() {
compare(2 + 2, 4, "QtTest harness is loaded and arithmetic still works")
}
function test_string_template() {
const x = 7
compare(`x is ${x}`, "x is 7", "QML template literals available")
}
}

View File

@@ -78,7 +78,13 @@ appimage: build staging-symfony ## Package as a single-file Linux AppImage at bu
@echo "AppImage built. Test with: ./build/Skeleton-x86_64.AppImage"
.PHONY: quality
quality: build ## Run PHPStan, php-cs-fixer (check), PHPUnit, qmllint, maker snapshots
quality: build qmltest ## Run PHPStan, php-cs-fixer (check), PHPUnit, qmllint, qmltest, maker snapshots
cd ../php && composer quality
cmake --build $(BUILD_DIR) --target all_qmllint
../php/tests/snapshot/run.sh
.PHONY: qmltest
qmltest: ## Run QML unit tests (Qt::QuickTest via qmltestrunner)
cmake -S ../qml -B ../qml/build-tests -DBUILD_TESTING=ON
cmake --build ../qml/build-tests --target qml_unit_tests --parallel
QT_QPA_PLATFORM=offscreen ctest --test-dir ../qml/build-tests --output-on-failure -R qml_unit_tests

View File

@@ -11,6 +11,7 @@
"symfony/security-bundle": "^8.0",
"symfony/mercure-bundle": "^0.4",
"symfony/uid": "^8.0",
"symfony/validator": "^8.0",
"doctrine/orm": "^3.0",
"doctrine/doctrine-bundle": "^3.0",
"doctrine/doctrine-migrations-bundle": "^4.0",

View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "dd53e6e42aa4773eaed84e9eaa374a68",
"content-hash": "c339068ebced1f2d3b2ce954e79f5ea6",
"packages": [
{
"name": "doctrine/collections",
@@ -1199,13 +1199,13 @@
"dist": {
"type": "path",
"url": "../../php",
"reference": "68fca95525db2311a08deb931f1b92909b20c450"
"reference": "b426d4a8ca67cde4f3bd0471d340e348b1fd4053"
},
"require": {
"doctrine/dbal": "^4.0",
"doctrine/doctrine-bundle": "^3.0",
"doctrine/orm": "^3.0",
"php": "^8.3",
"php": "^8.4",
"symfony/config": "^8.0",
"symfony/console": "^8.0",
"symfony/dependency-injection": "^8.0",
@@ -1259,7 +1259,7 @@
]
},
"license": [
"proprietary"
"LGPL-3.0-or-later"
],
"description": "Symfony bundle bridging PHP applications to a Qt/QML host (part of the php-qml framework).",
"transport-options": {
@@ -5024,6 +5024,88 @@
],
"time": "2026-03-30T15:14:47+00:00"
},
{
"name": "symfony/translation-contracts",
"version": "v3.6.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/translation-contracts.git",
"reference": "65a8bc82080447fae78373aa10f8d13b38338977"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/translation-contracts/zipball/65a8bc82080447fae78373aa10f8d13b38338977",
"reference": "65a8bc82080447fae78373aa10f8d13b38338977",
"shasum": ""
},
"require": {
"php": ">=8.1"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/contracts",
"name": "symfony/contracts"
},
"branch-alias": {
"dev-main": "3.6-dev"
}
},
"autoload": {
"psr-4": {
"Symfony\\Contracts\\Translation\\": ""
},
"exclude-from-classmap": [
"/Test/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Generic abstractions related to translation",
"homepage": "https://symfony.com",
"keywords": [
"abstractions",
"contracts",
"decoupling",
"interfaces",
"interoperability",
"standards"
],
"support": {
"source": "https://github.com/symfony/translation-contracts/tree/v3.6.1"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2025-07-15T13:41:35+00:00"
},
{
"name": "symfony/type-info",
"version": "v8.0.9",
@@ -5184,6 +5266,101 @@
],
"time": "2026-04-30T16:10:06+00:00"
},
{
"name": "symfony/validator",
"version": "v8.0.9",
"source": {
"type": "git",
"url": "https://github.com/symfony/validator.git",
"reference": "131dc8322c06595a6c98185787fa756deada20df"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/validator/zipball/131dc8322c06595a6c98185787fa756deada20df",
"reference": "131dc8322c06595a6c98185787fa756deada20df",
"shasum": ""
},
"require": {
"php": ">=8.4",
"symfony/polyfill-ctype": "^1.8",
"symfony/polyfill-mbstring": "^1.0",
"symfony/translation-contracts": "^2.5|^3"
},
"conflict": {
"doctrine/lexer": "<1.1",
"symfony/doctrine-bridge": "<7.4",
"symfony/expression-language": "<7.4"
},
"require-dev": {
"egulias/email-validator": "^2.1.10|^3|^4",
"symfony/cache": "^7.4|^8.0",
"symfony/config": "^7.4|^8.0",
"symfony/console": "^7.4|^8.0",
"symfony/dependency-injection": "^7.4|^8.0",
"symfony/expression-language": "^7.4|^8.0",
"symfony/finder": "^7.4|^8.0",
"symfony/http-client": "^7.4|^8.0",
"symfony/http-foundation": "^7.4|^8.0",
"symfony/http-kernel": "^7.4|^8.0",
"symfony/intl": "^7.4|^8.0",
"symfony/mime": "^7.4|^8.0",
"symfony/process": "^7.4|^8.0",
"symfony/property-access": "^7.4|^8.0",
"symfony/property-info": "^7.4|^8.0",
"symfony/string": "^7.4|^8.0",
"symfony/translation": "^7.4|^8.0",
"symfony/type-info": "^7.4|^8.0",
"symfony/yaml": "^7.4|^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\Validator\\": ""
},
"exclude-from-classmap": [
"/Tests/",
"/Resources/bin/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Provides tools to validate values",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/validator/tree/v8.0.9"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-04-30T16:10:06+00:00"
},
{
"name": "symfony/var-dumper",
"version": "v8.0.8",
@@ -5743,7 +5920,7 @@
"prefer-stable": false,
"prefer-lowest": false,
"platform": {
"php": "^8.3"
"php": "^8.4"
},
"platform-dev": {},
"plugin-api-version": "2.6.0"

View File

@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Controller;
use PhpQml\Bridge\Publisher;
use PhpQml\Bridge\PublisherInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
@@ -18,7 +18,7 @@ use Symfony\Component\Routing\Attribute\Route;
final class PingController
{
public function __construct(
private readonly Publisher $publisher,
private readonly PublisherInterface $publisher,
) {
}