16 Commits
v0.1.2 ... HEAD

Author SHA1 Message Date
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
62 changed files with 2443 additions and 143 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 \

View File

@@ -10,6 +10,46 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
- (none yet — next changes land here)
## [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
- **`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
Bugfix release. Bundles the v0.1.1 follow-up that surfaced during the v0.1.2 cycle (bundled-mode supervisor cleanly SIGTERMs its child on host exit) with three non-breaking fixes from a post-v0.1.1 architecture audit.
@@ -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

@@ -18,6 +18,7 @@ Developer documentation for [php-qml](../README.md). Design rationale and roadma
- **[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.
- **[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

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

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

@@ -0,0 +1,27 @@
# 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)
qt_add_executable(qml_unit_tests main.cpp)
target_link_libraries(qml_unit_tests PRIVATE
Qt6::QuickTest
Qt6::Qml
Qt6::Quick
)
# 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,9 @@
// 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 <QtQuickTest/quicktest.h>
QUICK_TEST_MAIN(qml_unit_tests)

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,
) {
}