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>
php-qml skeleton
The framework's reference application: a minimal Symfony backend plus a Qt/QML host, wired together exactly the way php-qml/bridge is intended to be used. Use this as a starting point or copy parts into your own project.
Prerequisites
- Linux (other platforms land in Phase 4)
- Qt 6.5+ dev packages (
qt6-base-devel,qt6-declarative-devel,qt6-quickcontrols2-devel,qt6-tools-devel), CMake, gcc-c++ - PHP 8.4+ (Symfony 8 enforces this)
- FrankenPHP on PATH (or set
FRANKENPHP=/path/to/frankenphp) - Composer
First run
make install # composer install in symfony/
make build # cmake + qt host
make doctor # bridge:doctor — readiness check
make dev # FrankenPHP --watch + Qt host, tears both down on Ctrl-C
make dev opens a Qt window, status dots flip to green, and the Ping button round-trips through /api/ping and back via Mercure SSE.
Adding a reactive resource
The headline workflow — add a Doctrine entity that ends up reactive in QML with three commands and zero handwritten glue:
cd symfony
# 1) Generate entity + controller + QML snippet
bin/console make:bridge:resource Todo
# created: src/Entity/Todo.php # #[BridgeResource] + UUIDv7
# created: src/Controller/TodoController.php
# created: ../qml/TodoList.qml
# 2) Schema migration (Doctrine reads the entity)
bin/console make:migration
bin/console doctrine:migrations:migrate -n
# 3) Use the generated TodoList.qml from your QML window.
That's it. The bundle's Doctrine subscriber automatically dual-publishes
postPersist/postUpdate/postRemove events to:
app://model/todo(collection topic, watched byReactiveListModel)app://model/todo/{id}(entity topic, watched byReactiveObject— Phase 3)
The ReactiveListModel in TodoList.qml does an initial GET /api/todos, subscribes to the collection topic, and applies diffs to its rows as they arrive. Adding --int-id switches the maker to auto-incrementing integer IDs.
Verifying end-to-end
With make dev running, post a todo from another terminal and watch it appear:
curl -X POST http://127.0.0.1:8765/api/todos \
-H 'Authorization: Bearer devtoken' \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: my-key-1' \
-d '{"title":"buy milk","done":false}'
The Mercure SSE stream receives a correlationKey: my-key-1 envelope which the Qt host's ReactiveListModel matches against any in-flight optimistic mutation (PLAN.md §5).
Hot reload
Both halves of the app reload without re-running make dev.
PHP-side (Symfony / FrankenPHP)
make dev runs frankenphp run --watch (see Caddyfile and scripts/dev.sh). FrankenPHP rebuilds the worker on any change under symfony/ — controllers, services, entities, templates, configuration. Just save the file; the next request through the Qt host hits the new code. There is no opcache to clear and no service to restart.
If you change something Doctrine-mapped, run a fresh migration in another terminal:
cd symfony
bin/console make:migration
bin/console doctrine:migrations:migrate -n
The Qt host stays up across all of this.
QML-side
The Qt host loads QML from a compiled-in resource bundle, so saving a .qml file does not flip the running window automatically. Three workflows that do:
- Qt Creator → File → Reload (or
Ctrl+Rwith focus on a QML file). Rebuilds the QML cache and reloads the window in place. qmllslive preview — the QML language server bundled with Qt 6.5+ runs a live preview connected to your editor (VSCode + the Qt extension, neovim, helix). Edits show up instantly in the preview window without rebuilding.- Run from source — start the host with
QT_QUICK_CONTROLS_CONF=andQML_IMPORT_TRACE=1set, and pass-DQT_QML_DEBUGso the running engine accepts a hot-reload connection from Qt Creator. PLAN.md §6 captures the long-term plan to gate this behindBRIDGE_DEV=1.
For most edits, Qt Creator's Reload is the lowest-friction option. The .qmlls.ini file (auto-generated when qmlls first runs) configures completion + live preview against this project's QML import paths.
Editor configs
Both .vscode/ and .idea/runConfigurations/ ship with the skeleton.
VSCode (.vscode/launch.json):
- Listen for Xdebug — attaches the debugger on port 9003 once you set
XDEBUG_MODE=debugfor the FrankenPHP child (e.g.XDEBUG_MODE=debug make dev). - Run skeleton (Qt host) — gdb-launches the built binary with
BRIDGE_URL=http://127.0.0.1:8765so it talks to the dev mode FrankenPHP started elsewhere bymake dev. - Compound: Dev: Xdebug + Qt host — runs both at once.
PhpStorm (.idea/runConfigurations/): make dev, make doctor, make quality shell run configs. PHP debugging is via the toolbar's Start Listening for PHP Debug Connections toggle (PhpStorm's Xdebug listener is global, not per-project).
Dev console
`Ctrl+`` toggles an in-window console showing the bundled FrankenPHP child's stdout + stderr (PLAN.md §8). It's a passive ring buffer (~500 lines) — opening it has no IPC cost. Use it when you don't have a separate terminal to read the dev log.
Quality checks
make quality # PHPStan + php-cs-fixer (check) + PHPUnit + qmllint
The same set CI runs (.gitea/workflows/ci.yml).
Layout
skeleton/
Caddyfile # FrankenPHP / Mercure config (dev mode)
Makefile # build / dev / doctor / quality
scripts/dev.sh # process-group teardown for FrankenPHP + Qt host
symfony/ # the Symfony app (composer project)
config/
src/Entity/ # filled by `make:bridge:resource`
src/Controller/ # filled by `make:bridge:resource`
public/index.php
bin/console
qml/ # the Qt host
CMakeLists.txt
main.cpp
Main.qml
<Name>List.qml # filled by `make:bridge:resource` (relative to symfony/)
Where to go next
- The framework code lives one level up:
framework/php(the bundle) andframework/qml(the Qt module). Both are linked into this skeleton via Composer's path repository / CMake'sadd_subdirectory, so edits there are picked up live. - The full design story is in
PLAN.md. - Phase 3 wires this skeleton into a real POC todo application with a multi-window test and packaging-CI dry runs.