Phase 2 sub-commit 5: convention test passes, skeleton walkthrough, phase 2 closed
Some checks failed
CI / Quality (push) Failing after 1m50s
Some checks failed
CI / Quality (push) Failing after 1m50s
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>
This commit is contained in:
98
framework/skeleton/README.md
Normal file
98
framework/skeleton/README.md
Normal file
@@ -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
|
||||
<Name>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.
|
||||
28
framework/skeleton/qml/TodoList.qml
Normal file
28
framework/skeleton/qml/TodoList.qml
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260502004612 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
101
framework/skeleton/symfony/src/Controller/TodoController.php
Normal file
101
framework/skeleton/symfony/src/Controller/TodoController.php
Normal file
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\Todo;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Serializer\SerializerInterface;
|
||||
|
||||
/**
|
||||
* Auto-generated CRUD controller for the Todo bridge resource.
|
||||
* Edit freely — re-running make:bridge:resource won't overwrite this file.
|
||||
*/
|
||||
#[Route('/api/todos')]
|
||||
final class TodoController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly SerializerInterface $serializer,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('', name: 'todo_list', methods: ['GET'])]
|
||||
public function list(): JsonResponse
|
||||
{
|
||||
$items = $this->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);
|
||||
}
|
||||
}
|
||||
54
framework/skeleton/symfony/src/Entity/Todo.php
Normal file
54
framework/skeleton/symfony/src/Entity/Todo.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use PhpQml\Bridge\Attribute\BridgeResource;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[BridgeResource(name: 'todo')]
|
||||
class Todo
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\Column(type: 'uuid', unique: true)]
|
||||
private Uuid $id;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
private string $title = '';
|
||||
|
||||
#[ORM\Column]
|
||||
private bool $done = false;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user