Phase 3 sub-commit 3: examples/todo POC app, built via the makers
Standalone Composer/CMake project under examples/todo/ derived from the
skeleton, demonstrating every Phase 3 architectural primitive in a
non-trivial app. All cross-side wiring is maker-generated; no
handwritten bridge glue.
Generated and customised:
- src/Entity/Todo.php — make:bridge:resource Todo (UUIDv7 id)
- src/Controller/TodoController.php — make:bridge:resource Todo (CRUD)
- src/Controller/MarkAllDoneController.php — make:bridge:command
MarkAllDone, body filled in to flip done=true on every row
- qml/TodoList.qml — make:bridge:resource Todo (starter ListView)
- qml/TodoWindow.qml — make:bridge:window Todo, body customised to
embed a read-only mirror of the same ReactiveListModel
The Phase 1 ping demo is dropped from this app — it doesn't fit the
todo flow and nothing in Main.qml references it.
Main.qml is the real list UI:
- Add input + button (POST /api/todos with optimistic-friendly key).
- Per-row CheckBox + delete button (PATCH/DELETE via
todoModel.invoke() with `pending` role driving opacity).
- "Mark all done" button (POST /api/mark-all-done).
- "Open second window" button (Component { TodoWindow {} } pattern).
Build / run delegated to the same Makefile shape as the skeleton, with
SCRIPT_DIR/QT_BIN updated for the renamed binary (build/qml/todo).
composer.json's path repo points at ../../../framework/php (one level
deeper than the skeleton's path repo).
Verified end-to-end with offscreen QPA: POST/PATCH/DELETE on /api/todos
all round-trip, /api/mark-all-done flips every row, Mercure dual-
publishes on every change. Clean shutdown.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
<?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\Routing\Attribute\Route;
|
||||
|
||||
/**
|
||||
* Marks every Todo as done. The Doctrine listener flushes one Mercure
|
||||
* envelope per affected row, so the QML list updates incrementally.
|
||||
*/
|
||||
final class MarkAllDoneController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/api/mark-all-done', name: 'mark-all-done', methods: ['POST'])]
|
||||
public function __invoke(Request $request): JsonResponse
|
||||
{
|
||||
$todos = $this->em->getRepository(Todo::class)->findBy(['done' => false]);
|
||||
foreach ($todos as $todo) {
|
||||
$todo->setDone(true);
|
||||
}
|
||||
$this->em->flush();
|
||||
|
||||
return new JsonResponse(['ok' => true, 'updated' => \count($todos)]);
|
||||
}
|
||||
}
|
||||
101
examples/todo/symfony/src/Controller/TodoController.php
Normal file
101
examples/todo/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
examples/todo/symfony/src/Entity/Todo.php
Normal file
54
examples/todo/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;
|
||||
}
|
||||
}
|
||||
13
examples/todo/symfony/src/Kernel.php
Normal file
13
examples/todo/symfony/src/Kernel.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
|
||||
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
|
||||
|
||||
final class Kernel extends BaseKernel
|
||||
{
|
||||
use MicroKernelTrait;
|
||||
}
|
||||
Reference in New Issue
Block a user