Two new makers complete the trio the todo POC needs:
`make:bridge:window <Name>`:
- emits {qml_path}/<Name>Window.qml — an ApplicationWindow wrapping
AppShell with a content slot to fill in. Apps open it via
Qt.createComponent() / a Component { } block to get extra
instances for the multi-window test (PLAN.md §13 Phase 3).
- pure-QML output, no PHP runtime deps.
`make:bridge:command <Name>`:
- emits src/Controller/<Name>Controller.php mounted at
POST /api/<kebab-name>. The body is a TODO stub that fills in
domain logic and flushes via the injected EntityManager —
Doctrine listeners pick up the changes and publish to Mercure
automatically. Synchronous by design (no Messenger plumbing for
a POC); apps that need async dispatch can add Messenger and
refactor.
Templates excluded from PHPStan / cs-fixer the same way the resource
maker's are. Smoke-tested both makers against `MarkAllDone` and
`AboutDialog` — output is correct PHP / QML and re-running them
reproduces byte-for-byte. composer quality stays green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ReactiveObject mirrors ReactiveListModel for a single entity. Loads via
GET <baseUrl><source>, stays in sync via Mercure SSE on `topic`, and
exposes the entity's JSON keys on a `data` QQmlPropertyMap so QML reads
them as `obj.data.title` with bindings that re-evaluate on change.
Properties:
- source / topic / baseUrl / token (configuration)
- data (QQmlPropertyMap*) — entity fields
- ready — initial fetch finished
- exists — entity present (false on 404 / delete)
- pending — at least one optimistic mutation in flight
- error
invoke(method, path, body, optimistic) is identical in shape to
ReactiveListModel.invoke(): apply optimistic to `data`, send the
request with an Idempotency-Key, clear `pending` on the matching
Mercure echo, roll back on 4xx/5xx or 10s timeout. The rollback
restores backed-up values and removes keys we added optimistically.
Wired into the QML module; the skeleton builds clean. Used by Phase 3
sub-commit 3's todo edit form.
Includes the merged CI trigger change (workflow now runs on `main`
branch only, not `dev` — keeps Gitea-runner pressure low while we're
iterating on dev).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a 5-step sub-commit sequence and the done-criteria for Phase 3.
Defaults baked in (subject to user override before sub-commit 1):
- Ship `make:bridge:command` and `make:bridge:window` only; defer
`make:bridge:event` and `make:bridge:read-model` since the todo
app doesn't use them. Phase 3.x can pick them up.
- Multi-window + crash-recover validated via a bridge-integration test
that boots a real FrankenPHP child + offscreen Qt host. qmltestrunner
smoke covers RestClient.qml and AppShell.qml.
- Maker-output snapshot test in CI catches silent generator drift by
diffing fresh maker runs against the checked-in baseline.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Runs `make:bridge:resource Todo` against the skeleton, then `make:migration`
+ `doctrine:migrations:migrate`, and verifies the round-trip end-to-end:
- POST /api/todos creates a row with a UUIDv7 id
- GET /api/todos returns the row
- Mercure dual-publishes:
- app://model/todo (collection topic)
- app://model/todo/{uuid} (entity topic)
- The published envelope shape matches PLAN.md §4 exactly:
{op:"upsert", id:..., version:..., data:{...}, correlationKey:"..."}
- correlationKey echoes the request's Idempotency-Key, ready to be
matched by ReactiveListModel's pending state on the QML side.
Generated files committed as the regression baseline (Phase 3 will add
a CI check that re-running the maker reproduces these byte-for-byte):
- framework/skeleton/symfony/src/Entity/Todo.php
- framework/skeleton/symfony/src/Controller/TodoController.php
- framework/skeleton/symfony/migrations/Version20260502004612.php
- framework/skeleton/qml/TodoList.qml
framework/skeleton/README.md captures the three-command flow plus a
curl walkthrough so future readers can reproduce. Phase 2 done.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bundle picks up symfony/maker-bundle as require-dev. New BridgeResourceMaker
under PhpQml\Bridge\Maker generates three files for a named resource:
- src/Entity/<Name>.php — Doctrine entity with #[BridgeResource]
and a UUIDv7 id by default. --int-id flips
to auto-incrementing int IDs.
- src/Controller/<Name>Controller.php — CRUD on /api/{plural} (list,
create, update, delete) with serializer-
normalised JSON responses.
- {qml_path}/<Name>List.qml — starter ListView wrapped around a
ReactiveListModel bound to the right topic
and source URL.
The Doctrine subscriber from sub-commit 2 picks the entity up
automatically — no per-resource listener generated. The QML snippet
target defaults to '../qml/' (relative to the Symfony project root)
and is overridable via the maker's $qmlPath constructor arg.
Templates live under src/Maker/templates/ as .tpl.php files using
short-echo and alternative-syntax control structures by convention.
PHPStan and php-cs-fixer skip them — the maker's Generator binds the
template variables at render time.
Skeleton picks up MakerBundle as a `dev` bundle and require-dev'd
symfony/maker-bundle, so `bin/console make:bridge:resource Todo`
works out-of-the-box.
Verified: maker runs end-to-end against `Todo` and emits readable,
syntactically valid output. composer quality (16 tests, 45 assertions,
PHPStan clean, cs-fixer clean) stays green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
BackendConnection's ConnectionState enum is now Connecting / Online /
Reconnecting / Offline (PLAN.md §5). The probe loop tracks the first
failure since the last Online and transitions to Reconnecting on any
failed probe, then to Offline once the configurable threshold (30 s
default) is exceeded. The Error state is gone; Reconnecting + the
exposed `error` string subsume its UI role.
ReactiveListModel is the headline QML type:
- QAbstractListModel that GETs `baseUrl + source` for an initial JSON
array and then keeps in sync via an internal MercureClient subscribed
to `topic`.
- Role names are derived dynamically from the first row's keys plus an
internal `pending` boolean role used by optimistic mutations.
- Diff application: upsert (insert-or-update), delete, replace; gap
detection via the envelope `version` field with auto re-fetch.
- `invoke(method, path, body, optimistic)` is the optimistic command
primitive. Generates an Idempotency-Key, applies the local diff,
POST/PATCH/DELETEs with that key, and resolves on the matching
Mercure echo (correlation-key matched in ModelPublisher's envelope).
Rolls back and emits commandFailed on 4xx/5xx, commandTimedOut after
10 s without an echo. Phase 4 packaging will surface configuration
for the timeout.
AppShell.qml is the optional convenience root:
- Reads BackendConnection.connectionState.
- Reconnecting → top banner.
- Offline → modal overlay with the error string and a Retry button
(calls BackendConnection.restart()).
- Wraps user content via `default property alias content`.
Apps that want full chrome control can skip AppShell entirely; the
skeleton's Main.qml keeps its own status display for demonstration
and is unaffected.
ReactiveObject (single-entity twin of ReactiveListModel) is intentionally
deferred — same envelope handling, smaller surface; will land in Phase 2
follow-up or Phase 3 alongside the todo example. Cursor pagination is
similarly deferred (the Phase 2 done criterion uses small lists).
Smoke tested: /healthz + /api/ping round-trip cleanly, zero Mercure 401s,
clean shutdown. composer quality stays green (16 tests, 45 assertions).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bundle gains the model layer that bridges Doctrine entities to Mercure
without per-resource glue. Three new pieces:
- `#[BridgeResource(name: ?string)]` attribute marks an entity as a
reactive bridge model. Topic name defaults to the lowercased class
basename and can be overridden per resource.
- `ModelPublisher` translates entity changes into PLAN.md §4 envelopes
({op, id, data, version, ?correlationKey}) and dual-publishes them
on `app://model/{name}` (collection topic) and `app://model/{name}/{id}`
(entity topic). Entity normalisation goes through Symfony's Serializer
(ObjectNormalizer + DateTime + BackedEnum) for predictable JSON. The
envelope `version` field is a per-process monotonic counter — fine for
single-instance dev mode; production should back this with a Postgres
SEQUENCE or equivalent (noted for Phase 4).
- `DoctrineBridgeListener` registers `postPersist`/`postUpdate`/
`postRemove` via `#[AsDoctrineListener]` and routes events through
ModelPublisher. Entities without `#[BridgeResource]` are silently
skipped.
Plus the correlation-key plumbing the §5 Update Semantics layer needs:
- `CorrelationContext` is a per-request holder for the originating
request's `Idempotency-Key`.
- `CorrelationKeyListener` reads the header on `KernelEvents::REQUEST`
and clears the context on `KernelEvents::TERMINATE` (worker mode
hygiene). CLI mutations see no key, which is correct.
Bundle composer.json picks up `doctrine/dbal`, `doctrine/orm`,
`doctrine/doctrine-bundle`, `symfony/serializer`, `symfony/property-*`,
`symfony/uid`. PHPStan extension `phpstan-doctrine` added so the listener's
event-args types resolve. Skeleton's framework.yaml enables `serializer`
and `property_info`.
Tests: 5 new for ModelPublisher (dual publish, correlation echo, delete
op omits data, untagged entities ignored, version increments). Total:
16 tests, 45 assertions, PHPStan clean, cs-fixer clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Skeleton gains Doctrine ORM 3.6 (with DoctrineBundle 3.x and Migrations
4.x), pointed at a SQLite file under var/data.sqlite. Apps move to
Postgres/MySQL by overriding DATABASE_URL in .env.local.
config/packages/doctrine.yaml registers the symfony/uid UuidType so
Phase 2 sub-commit 4's UUIDv7 default works without per-app config,
and pre-wires the App\Entity attribute mapping under src/Entity/ for
the maker to drop entities into.
Bundle gains an optional doctrine/dbal Connection via Autowire; when
present, bridge:doctor adds a "Database reachable" SELECT-1 probe.
The bundle still installs cleanly without doctrine/dbal — apps that
opt out get a doctor table without the database row.
Verified: `bin/console bridge:doctor` is all green against a fresh
SQLite. composer quality (PHPStan + cs-fixer + PHPUnit) stays green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Makes the time-ordered, distributed-friendly UUIDv7 (via symfony/uid)
the framework's default. Auto-increment integers remain available via
an explicit --int-id flag on the maker.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a stack-additions table (ORM, dev DB, default ID type, pagination
strategy, Doctrine→Mercure trigger), a 5-step sub-commit sequence, and
the done-criteria for Phase 2. Defaults baked in (subject to user
override before sub-commit 1 starts):
- Doctrine ORM 3.x with DoctrineBundle + DoctrineMigrationsBundle
- SQLite at var/data.sqlite for dev
- Auto-incrementing int IDs by default; the maker takes a UUIDv7 flag
- Cursor-based pagination, default page size 50
- Synchronous postPersist/postUpdate/postRemove subscribers
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The framework skeleton at framework/skeleton/ now exercises every
transport channel the spike proved (HTTP /api/ping, Mercure publish,
SSE subscribe, FrankenPHP child management, single-instance lock,
clean shutdown) — but through the proper PhpQml.Bridge module rather
than ad-hoc inline QML. Lessons from the spike are preserved in PLAN.md
and in the framework code's comments where they apply.
Closes Phase 1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PHPStan (level 6 + symfony extension) and PHP CS Fixer (Symfony +
PHP83Migration ruleset) configs at framework/php/. composer.json
exposes phpstan / cs:check / cs:fix / phpunit / quality scripts.
PHPStan-clean across the bundle; cs:check is happy after auto-fix
applied @Symfony idioms (yoda, leading-backslash JSON_*, blank-line
before return). Test mocks consolidated into a HubSpy helper to keep
PHPStan happy about by-ref captures.
Skeleton's Makefile target `quality` chains `composer quality` (in
framework/php/) with cmake's all_qmllint target. Local run is green —
11 tests / 32 assertions, no PHPStan errors, cs-fixer clean, qmllint
emits advisory warnings only.
Layout fix in skeleton's Main.qml: status-dot Rectangles inside
RowLayout now use Layout.preferredWidth/Height instead of width/height
to satisfy Quick.layout-positioning checks.
.gitea/workflows/ci.yml replaces the placeholder with a real `quality`
job: setup-php, composer install (cached), the four PHP checks, Qt 6
via install-qt-action (cached), QML module build, qmllint via the
all_qmllint CMake target. Workflow exists from this commit onward
even if a runner isn't provisioned yet.
bridge:doctor lost the Publisher dependency since it was only used as
a "service is wired" marker — the command being injectable already
proves that.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Symfony app under framework/skeleton/symfony/: minimal bin/console,
public/index.php, MicroKernel-based src/Kernel.php, services.yaml,
framework/security/mercure config, and a demo App\Controller\PingController
that GETs /api/ping (returning JSON pong) and republishes the same
payload to the Mercure topic app://ping. composer.json uses a path
repository to symlink the bundle from ../../php so local edits are
picked up live.
QML app under framework/skeleton/qml/: top-level CMake that
add_subdirectory's framework/qml, a main.cpp that creates the Qt
process, runs SingleInstance.acquireOrForward before any QML loads,
exposes SingleInstance via context property, and loadFromModule's
Skeleton.Main. Main.qml uses BackendConnection / RestClient /
MercureClient from PhpQml.Bridge and renders status dots, a Ping
button, and an event log.
Caddyfile binds 127.0.0.1:8765, enables in-memory Mercure with a
256-bit dev JWT (matches symfony/.env, lcobucci/jwt requires this).
Makefile wraps build / dev / doctor / clean; scripts/dev.sh starts
FrankenPHP --watch and the Qt host together with explicit PID-based
teardown (process-group `kill 0` proved unreliable when frankenphp's
watch fork reparented).
Bug fixes uncovered in this sub-commit:
- SingleInstance.acquireOrForward: probe-first, then removeServer +
retry-listen. The original loop-with-removeServer-after-failed-bind
silently exited on stale sockets from prior runs.
- Main.qml: MercureClient does NOT inherit BackendConnection.token —
Mercure subscribes anonymously in dev (Caddyfile), and forwarding
the bridge bearer made it 401-loop.
- /api/ping was 500ing because the dev MERCURE_JWT_SECRET was 144 bits;
bumped to 64-char (>=256 bit) to satisfy lcobucci/jwt.
- Linked the framework lib (php_qml_bridge) explicitly in addition to
the QML plugin so SingleInstance.h resolves.
- Auto-generated config/reference.php gitignored.
Smoke verified offscreen: /healthz 200, /api/ping 200, 1 publish, 1
subscriber, zero 401s, clean shutdown with no zombies.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
MercureClient is a single-topic SSE subscriber: opens a long-lived GET
on the hub URL with the topic query and Accept: text/event-stream,
parses the line protocol into update(data, id) signals, and reconnects
with 1s→2s→…→30s exponential backoff on drop. Tracks lastEventId across
reconnects and sends it as Last-Event-ID so the hub can replay missed
messages — backing the "Sleep / wake" path in PLAN.md §3 *Edge cases*.
One client per topic by design; multi-topic aggregation is Phase 2.
RestClient.qml is a Promise-style XMLHttpRequest wrapper. Auto-attaches
an RFC4122-v4 Idempotency-Key to every non-GET request (PLAN.md §4 and
§7) so retries are safe by default. Maps application/problem+json error
bodies into structured rejections for downstream UI.
Standalone CMake build remains green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
BackendConnection (QML singleton via create() factory) reads BRIDGE_URL
and BRIDGE_TOKEN from env, periodically probes <url>/healthz with a 2s
transfer timeout, and exposes a Connecting/Online/Error state machine
plus error/token properties to QML. Bundled-mode startup (spawning the
embedded FrankenPHP child) is a Phase 4 deliverable; restart() is a
no-op for now. tokenRotated signal is reserved for the per-session
secret rotation described in PLAN.md §3.
SingleInstance is C++-only — main() must call acquireOrForward() before
the QML engine boots, so it's exposed via context property rather than
QML_SINGLETON. QLocalServer-based lock with stale-socket detection,
launch-arg forwarding via QDataStream, and the deadlock-avoiding race
fallback specified in §3 *Edge cases*.
CMakeLists.txt declares the PhpQml.Bridge static QML module with both
sources and is dual-mode: stands alone for sanity builds, integrates
via add_subdirectory from the skeleton's top-level CMake (Phase 1
sub-commit 6). Standalone build verified clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Console command bridge:doctor surfaces actionable hints for env / wiring
problems so first-run failures aren't a "connection refused" mystery.
Checks PHP version, ext-curl, ext-json, the Publisher service is wired
(meaning BridgeBundle loaded), and the BRIDGE_TOKEN / MERCURE_URL /
MERCURE_PUBLISHER_JWT_KEY / MERCURE_SUBSCRIBER_JWT_KEY env vars. With
--connect, also probes the configured URL via plain stream context (no
extra dep) and fails the run when unreachable.
CommandTester suite covers green path, missing-env path, and an
unreachable-URL probe — 11 tests, 32 assertions, all green.
Skeleton's Makefile target stays a TBD until sub-commit 6 stands up the
Symfony app the command runs from.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bundle code for php-qml/bridge: BridgeBundle (AbstractBundle, autoloads
config/services.yaml), Publisher (thin wrapper over Mercure HubInterface
that enforces envelope-as-JSON), SessionAuthenticator (bearer-token
custom Symfony authenticator with problem+json failures), and
HealthController (GET /healthz readiness probe).
Composer constraints bumped to Symfony ^8.0 across the board (per user
request); mercure component to ^0.7. PHPUnit 11 suite covers Publisher
publish + private flag and SessionAuthenticator support/auth/failure
paths — 8 tests, 22 assertions, all green.
PLAN.md §13 updated to record the Symfony 8 minimum.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Stands up the directory structure Phase 1 fills in over subsequent
sub-commits: framework/php (Composer package php-qml/bridge),
framework/qml (Qt module placeholder), framework/skeleton (Caddyfile +
Makefile stubs), and .gitea/workflows/ci.yml. Root .gitignore covers
the build/composer/Symfony/Qt/CMake/IDE artefacts the rest of Phase 1
will produce. No bundle code, no Qt module sources, no working dev mode
yet — those land in sub-commits 2-7. Spike still in place.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds naming/identifier table, directory layout, eight sub-commits, and
done criteria for Phase 1. Also swaps the planned Taskfile for a plain
Makefile across §8 and §13 — Make is universal and skips a dependency
that openSUSE Tumbleweed doesn't package.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bare PHP behind FrankenPHP plus a Qt/QML host that spawns it. GET /api/ping
publishes a Mercure event; the QML window receives it back over the SSE
stream. Findings (Caddy directive ordering, Mercure transport scalar,
PR_SET_PDEATHSIG for child cleanup, PHP 8.5 curl_close deprecation, port
collision with system FrankenPHP, pure-QML SSE viability) are recorded in
spike/README.md so Phase 1 starts from a known-good baseline.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the spike's layout, flow, hardcoded values, and done criteria so
implementation has a clear target. No code yet.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Architectural design (PLAN.md) and project intro committed before any
implementation begins.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>