From 1964a52f997b733ebb4889b7c2c5f28badfbd014 Mon Sep 17 00:00:00 2001 From: magdev Date: Sat, 2 May 2026 02:49:23 +0200 Subject: [PATCH] Phase 2 sub-commit 5: convention test passes, skeleton walkthrough, phase 2 closed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- framework/skeleton/README.md | 98 +++++++++++++++++ framework/skeleton/qml/TodoList.qml | 28 +++++ .../migrations/Version20260502004612.php | 31 ++++++ .../symfony/src/Controller/TodoController.php | 101 ++++++++++++++++++ .../skeleton/symfony/src/Entity/Todo.php | 54 ++++++++++ 5 files changed, 312 insertions(+) create mode 100644 framework/skeleton/README.md create mode 100644 framework/skeleton/qml/TodoList.qml create mode 100644 framework/skeleton/symfony/migrations/Version20260502004612.php create mode 100644 framework/skeleton/symfony/src/Controller/TodoController.php create mode 100644 framework/skeleton/symfony/src/Entity/Todo.php diff --git a/framework/skeleton/README.md b/framework/skeleton/README.md new file mode 100644 index 0000000..e2525bf --- /dev/null +++ b/framework/skeleton/README.md @@ -0,0 +1,98 @@ +# 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.3+ +- [FrankenPHP](https://frankenphp.dev/) on PATH (or set `FRANKENPHP=/path/to/frankenphp`) +- Composer + +## First run + +```bash +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: + +```bash +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 by `ReactiveListModel`) +- `app://model/todo/{id}` (entity topic, watched by `ReactiveObject` — 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: + +```bash +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). + +## Quality checks + +```bash +make quality # PHPStan + php-cs-fixer (check) + PHPUnit + qmllint +``` + +The same set CI runs (`.gitea/workflows/ci.yml`). + +## Layout + +```text +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 + List.qml # filled by `make:bridge:resource` (relative to symfony/) +``` + +## Where to go next + +- The framework code lives one level up: [`framework/php`](../php) (the bundle) and [`framework/qml`](../qml) (the Qt module). Both are linked into this skeleton via Composer's path repository / CMake's `add_subdirectory`, so edits there are picked up live. +- The full design story is in [`PLAN.md`](../../PLAN.md). +- Phase 3 wires this skeleton into a real POC todo application with a multi-window test and packaging-CI dry runs. diff --git a/framework/skeleton/qml/TodoList.qml b/framework/skeleton/qml/TodoList.qml new file mode 100644 index 0000000..5e7c339 --- /dev/null +++ b/framework/skeleton/qml/TodoList.qml @@ -0,0 +1,28 @@ +// Auto-generated by `bin/console make:bridge:resource Todo`. +// Drop this into your QML and customize the delegate to taste. + +import QtQuick +import QtQuick.Controls +import PhpQml.Bridge + +ListView { + id: todoList + + model: ReactiveListModel { + baseUrl: BackendConnection.url + token: BackendConnection.token + source: "/api/todos" + topic: "app://model/todo" + } + + delegate: ItemDelegate { + required property string id + required property string title + required property bool done + required property bool pending + + text: title + (done ? " ✓" : "") + opacity: pending ? 0.5 : 1.0 + width: ListView.view.width + } +} diff --git a/framework/skeleton/symfony/migrations/Version20260502004612.php b/framework/skeleton/symfony/migrations/Version20260502004612.php new file mode 100644 index 0000000..84b3255 --- /dev/null +++ b/framework/skeleton/symfony/migrations/Version20260502004612.php @@ -0,0 +1,31 @@ +addSql('CREATE TABLE todo (id BLOB NOT NULL, title VARCHAR(255) NOT NULL, done BOOLEAN NOT NULL, PRIMARY KEY (id))'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('DROP TABLE todo'); + } +} diff --git a/framework/skeleton/symfony/src/Controller/TodoController.php b/framework/skeleton/symfony/src/Controller/TodoController.php new file mode 100644 index 0000000..dbdf9f8 --- /dev/null +++ b/framework/skeleton/symfony/src/Controller/TodoController.php @@ -0,0 +1,101 @@ +em->getRepository(Todo::class)->findAll(); + + return new JsonResponse($this->serializer->normalize($items, 'json')); + } + + #[Route('', name: 'todo_create', methods: ['POST'])] + public function create(Request $request): JsonResponse + { + $data = json_decode((string) $request->getContent(), true) ?? []; + $entity = new Todo(); + + if (isset($data['title'])) { + $entity->setTitle((string) $data['title']); + } + + if (isset($data['done'])) { + $entity->setDone((bool) $data['done']); + } + + $this->em->persist($entity); + $this->em->flush(); + + return new JsonResponse( + $this->serializer->normalize($entity, 'json'), + Response::HTTP_CREATED, + ); + } + + #[Route('/{id}', name: 'todo_update', methods: ['PATCH'])] + public function update(string $id, Request $request): 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'], + ); + } + + $data = json_decode((string) $request->getContent(), true) ?? []; + + if (isset($data['title'])) { + $entity->setTitle((string) $data['title']); + } + + if (isset($data['done'])) { + $entity->setDone((bool) $data['done']); + } + + $this->em->flush(); + + return new JsonResponse($this->serializer->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); + } +} diff --git a/framework/skeleton/symfony/src/Entity/Todo.php b/framework/skeleton/symfony/src/Entity/Todo.php new file mode 100644 index 0000000..b441a9a --- /dev/null +++ b/framework/skeleton/symfony/src/Entity/Todo.php @@ -0,0 +1,54 @@ +id = Uuid::v7(); + } + + public function getId(): Uuid + { + return $this->id; + } + + public function getTitle(): string + { + return $this->title; + } + + public function setTitle(string $title): void + { + $this->title = $title; + } + + public function isDone(): bool + { + return $this->done; + } + + public function setDone(bool $done): void + { + $this->done = $done; + } +}