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>
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>
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>
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>
The previous commit (0cceefc) staged the audit-driven fixes under a
hypothetical v0.1.3 since v0.1.2 hadn't tagged yet. Tagging cadence
decision: ship them all as v0.1.2 — there's no point spending a tag
on the clean-shutdown fix alone when v0.1.3 was already lined up
behind it. PLAN.md §13 + CHANGELOG.md collapse v0.1.2 + v0.1.3 into
the single v0.1.2 entry, dated 2026-05-03; orphaned [0.1.3] link
reference at the foot of CHANGELOG removed.
No code changes — the four fixes from 0cceefc + f132c3c stand as-is.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three bugs surfaced by the post-v0.1.2 architecture audit:
- bridge.qml_path is now actually configurable. BridgeBundle::configure
defines the qml_path scalar node (default ../qml/); loadExtension
exposes it as the bridge.qml_path container parameter; services.yaml
binds it into BridgeResourceMaker + BridgeWindowMaker. Apps override
with `config/packages/bridge.yaml`. The existing maker docstrings
claimed this worked already — they lied; now they don't.
- SessionAuthenticator implements AuthenticationEntryPointInterface and
routes the no-token entry-point path through the same problem+json
helper as onAuthenticationFailure, so QML's RestClient sees one error
shape regardless of which firewall path was taken. Test added.
- CorrelationKeyListener::onTerminate guards on isMainRequest() now,
matching onRequest's existing guard. No user-visible impact in
worker mode (no sub-requests emitted), but the asymmetry was a
defensive bug that would corrupt optimistic-update reconciliation.
PLAN.md §13 gains a v0.1.3 section + folds the audit's API-surface
items (PublisherInterface / ModelPublisherInterface / BridgeOp enum /
maker DRY / DTO-shaped scaffold) into v0.2.0. CHANGELOG.md mirrors.
PHPStan + cs-fixer + PHPUnit (17/17) + maker snapshot tests all green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Symptom (user report on v0.1.1):
QProcess: Destroyed while process ("/tmp/.mount_Todo-xbNoHHL/usr/bin/frankenphp") is still running.
…and the frankenphp child + its PHP workers were left orphaned after
the host exited.
Cause: teardownChild() was only called from ~BackendConnection. By
the time that destructor runs, app.exec() has already returned,
QQmlApplicationEngine is mid-destruction, and Qt's event loop is
half-torn-down. waitForFinished() doesn't reliably reap the child in
that window — QProcess gets destroyed by the QObject parent-chain
cleanup before the kernel reports the child as exited.
Fix: in BackendConnection's constructor, connect
QCoreApplication::aboutToQuit → teardownChild. aboutToQuit fires
while the event loop is still active and BEFORE main() starts
unwinding the stack, so SIGTERM + waitForFinished can do their job
properly. The destructor's teardownChild call stays as belt-and-
suspenders (no-op once aboutToQuit has already cleaned up — the
function is idempotent via the m_child = nullptr at its end).
The connect happens unconditionally in the constructor (not just for
bundled mode) because m_child is also nullptr in dev mode and
teardownChild handles that with its leading `if (!m_child) return;`.
Regression guard: examples/todo/tests/bundled-supervisor.sh gains a
"graceful shutdown" step:
- Snapshots the host's child PIDs before SIGTERM
- SIGTERMs the host, waits up to 3s for clean exit
- Greps the host log for "QProcess: Destroyed while" — fail if found
- Iterates the snapshotted PIDs, fails on any frankenphp orphan still alive
Verified locally: real AppImage + the integration test both clean up
without Qt warnings or orphan processes.
PLAN.md: new v0.1.2 section above v0.1.1, this is its first entry.
CHANGELOG.md: [0.1.2] — TBD section with the same Fixed entry.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reproduces with the v0.1.1 AppImage on the second launch (same user
data dir, fresh AppImage mount):
phpqml.bridge.bundled: symfony: "/tmp/.mount_Todo-xllnOHH/..."
Cannot load migrations from "/tmp/.mount_Todo-xDBkOfG/.../migrations"
^^^^^^^
stale path from PREVIOUS launch's cache
Symfony compiles `kernel.project_dir` (an absolute path) into its
cached container under var/cache/. We redirect var/cache into the
user data dir for read-only-mount survival (v0.1.0 fix), but the
*content* of that cache references the mount path that was active
when the cache was built. Next launch gets a different
/tmp/.mount_<random>; the cached refs are stale; first
project_dir-sensitive lookup blows up (doctrine migrations was the
canary; would also surface as misrouted assets, broken Twig template
paths, etc.).
Fix: BackendConnection::initBundledMode does
QDir(cacheDir).removeRecursively() right after creating the dirs but
before runMigrations spawns the doctrine subprocess. Symfony rebuilds
the cache against the current mount on every launch. Cost: ~1-2s of
warmup per cold start.
Permanent fix is build-time cache warmup (ship the prod cache inside
the AppImage, copy to user data dir on first launch, no per-launch
warmup) — already tracked as a v0.2.0 item in PLAN.md §13. v0.1.1
takes the simpler always-wipe approach since it's bugfix-class.
Regression guard: examples/todo/tests/bundled-supervisor.sh gains a
"2nd launch from fresh staging" step that tears down the first host,
re-stages a fresh fake AppImage layout (different /tmp dir = different
"mount path" from BackendConnection's perspective), and asserts
/healthz comes back up. Without the cache wipe, that step would fail
exactly the way doctrine did in the user's report.
Verified locally:
- bundled-supervisor.sh passes (incl. 2nd-launch step)
- Real AppImage: two consecutive launches both reach
"phpqml.bridge.bundled: migrations OK" + frankenphp spawn
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CHANGELOG.md: new [0.1.1] section with Fixed (HealthController
deep-load, Caddyfile fmt) + Added (bundled-mode supervisor test,
skeleton AppImage parity) + a Notes line acknowledging the
port-collision bug deferred to v0.2.0. Date stays TBD until tag push.
Compare/tag link refs updated.
PLAN.md: v0.1.1 section flipped from "open follow-ups" to "ready to
tag" with each item describing what shipped (handy for the release
notes pass). v0.2.0 section gains an explicit "Bundled-mode port
negotiation" entry under Operations — the port-collision bug
surfaced during v0.1.1 prep, but the fix touches every consumer that
hardcodes 8765 (perfsmoke, the new bundled-supervisor test) so it's
wider than v0.1.x scope. Status line at the head of the file bumped
to "v0.1.1 ready to tag".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the two release-prep items called out in the Phase 5 closure
paragraph (a3d35a7).
License: LGPL-3.0-or-later. Chosen to align with Qt 6's LGPLv3, which
keeps the AppImage's relinkability obligation (PLAN.md §12) satisfied
and avoids version-mixing friction with upstream Qt. Two files at the
repo root:
- LICENSE — LGPL-3.0 text (the project license).
- LICENSE.GPL — GPL-3.0 text the LGPL-3.0 explicitly incorporates
("This version of the GNU Lesser General Public
License incorporates the terms and conditions of
version 3 of the GNU General Public License…").
framework/php/composer.json: "license": "proprietary" → SPDX
"LGPL-3.0-or-later". CHANGELOG Notes section updated with the actual
license + LICENSE/LICENSE.GPL pointer.
Repo URL: every `gitea.example/<org|you>/php-qml` (and `<org>/<repo>`
in docs/packaging-linux.md) replaced with the real
`src.bundespruefstelle.ch/magdev/php-qml`. Touched README.md,
CHANGELOG.md (compare + tag links), docs/getting-started.md,
docs/packaging-linux.md (build-appimage --update-info example +
latest.json appcast example).
PLAN.md: status line bumped to "v0.1.0 ready to tag — LGPL-3.0-or-later
license shipped, repo URL fixed". Phase 5 closure paragraph rewritten
to record both items resolved (rather than pending).
Only remaining manual edit at tag time: CHANGELOG `[0.1.0] — TBD` →
`[0.1.0] — YYYY-MM-DD` (per Keep-a-Changelog), and the actual
`git tag v0.1.0 && git push --tags` itself, which triggers
.gitea/workflows/release.yml. Per the branching memory, releases land
on main — merge dev → main first.
Verified: `make quality` from framework/skeleton green (16 tests, 45
assertions; PHPStan + cs-fixer clean; maker snapshots match).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
README.md rewritten to reflect actual onboarding (clone → php-qml-init
→ make dev / make appimage) instead of the planning-stage placeholder.
Phase status checklist now reflects 0–4a green and 5 in progress.
CHANGELOG.md created at repo root following Keep-a-Changelog
conventions, with a v0.1.0 entry that summarises Phases 0–4a plus the
Phase 5 polish work (DevConsole, php-qml-init, editor configs,
hot-reload docs). Date is TBD; tagging is the user's call.
PLAN.md gains a Status banner so it's obvious at a glance which phase
the implementation tracks against the design.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>