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:
2026-05-02 15:22:36 +02:00
parent 9c97984bc9
commit 15f9aa032e
28 changed files with 6793 additions and 0 deletions

View File

@@ -0,0 +1,21 @@
APP_ENV=dev
APP_DEBUG=1
APP_SECRET=dev-secret-not-for-production-use
# Mercure hub (FrankenPHP-built-in). Both URLs are typically the same in
# dev mode where the hub and the app are colocated.
MERCURE_URL=http://127.0.0.1:8765/.well-known/mercure
MERCURE_PUBLIC_URL=http://127.0.0.1:8765/.well-known/mercure
# Used by mercure-bundle to mint publisher JWTs. Must match the
# publisher_jwt secret in ../Caddyfile. lcobucci/jwt requires HMAC
# secrets to be at least 256 bits, so the dev value here is 64 chars.
MERCURE_JWT_SECRET=dev_php_qml_bridge_jwt_secret_at_least_256_bits_long_for_lcobucci
MERCURE_PUBLISHER_JWT_KEY=dev_php_qml_bridge_jwt_secret_at_least_256_bits_long_for_lcobucci
MERCURE_SUBSCRIBER_JWT_KEY=dev_php_qml_bridge_jwt_secret_at_least_256_bits_long_for_lcobucci
# Bearer token the Qt host sends on /api/* requests.
BRIDGE_TOKEN=devtoken
# SQLite database for dev. Apps move to Postgres / MySQL by overriding
# DATABASE_URL in .env.local once they outgrow it.
DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.sqlite"

View File

@@ -0,0 +1,17 @@
#!/usr/bin/env php
<?php
use App\Kernel;
use Symfony\Bundle\FrameworkBundle\Console\Application;
if (!is_file(dirname(__DIR__).'/vendor/autoload_runtime.php')) {
fwrite(STDERR, "Vendor autoload missing. Run `composer install` in the skeleton/symfony dir first.\n");
exit(1);
}
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
return function (array $context): Application {
$kernel = new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
return new Application($kernel);
};

View File

@@ -0,0 +1,46 @@
{
"type": "project",
"license": "proprietary",
"minimum-stability": "stable",
"require": {
"php": "^8.3",
"symfony/framework-bundle": "^8.0",
"symfony/runtime": "^8.0",
"symfony/dotenv": "^8.0",
"symfony/yaml": "^8.0",
"symfony/security-bundle": "^8.0",
"symfony/mercure-bundle": "^0.4",
"symfony/uid": "^8.0",
"doctrine/orm": "^3.0",
"doctrine/doctrine-bundle": "^3.0",
"doctrine/doctrine-migrations-bundle": "^4.0",
"php-qml/bridge": "@dev"
},
"require-dev": {
"symfony/maker-bundle": "^1.62"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"config": {
"allow-plugins": {
"symfony/runtime": true
}
},
"extra": {
"runtime": {
"class": "Symfony\\Component\\Runtime\\SymfonyRuntime"
}
},
"repositories": [
{
"type": "path",
"url": "../../../framework/php",
"options": {
"symlink": true
}
}
]
}

5750
examples/todo/symfony/composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,11 @@
<?php
return [
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true],
Symfony\Bundle\MercureBundle\MercureBundle::class => ['all' => true],
Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true],
Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true],
Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
PhpQml\Bridge\BridgeBundle::class => ['all' => true],
];

View File

@@ -0,0 +1,38 @@
doctrine:
dbal:
url: '%env(resolve:DATABASE_URL)%'
# SQLite default for dev — see .env.
# Apps swap this to Postgres / MySQL when they outgrow it.
types:
uuid: Symfony\Bridge\Doctrine\Types\UuidType
orm:
validate_xml_mapping: true
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
identity_generation_preferences:
Doctrine\DBAL\Platforms\PostgreSQLPlatform: identity
auto_mapping: true
mappings:
App:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Entity'
prefix: 'App\Entity'
alias: App
when@prod:
doctrine:
orm:
query_cache_driver:
type: pool
pool: doctrine.system_cache_pool
result_cache_driver:
type: pool
pool: doctrine.result_cache_pool
framework:
cache:
pools:
doctrine.result_cache_pool:
adapter: cache.app
doctrine.system_cache_pool:
adapter: cache.system

View File

@@ -0,0 +1,4 @@
doctrine_migrations:
migrations_paths:
'DoctrineMigrations': '%kernel.project_dir%/migrations'
enable_profiler: false

View File

@@ -0,0 +1,13 @@
framework:
secret: '%env(APP_SECRET)%'
http_method_override: false
handle_all_throwables: true
php_errors:
log: true
router:
utf8: true
serializer:
enabled: true
property_info:
enabled: true
test: false

View File

@@ -0,0 +1,9 @@
mercure:
hubs:
default:
url: '%env(MERCURE_URL)%'
public_url: '%env(MERCURE_PUBLIC_URL)%'
jwt:
secret: '%env(MERCURE_JWT_SECRET)%'
publish: ['*']
subscribe: ['*']

View File

@@ -0,0 +1,18 @@
security:
providers:
bridge_provider:
memory:
users:
bridge: { roles: ['ROLE_BRIDGE'] }
firewalls:
unauth:
pattern: ^/(healthz|\.well-known/mercure)
security: false
api:
pattern: ^/api
stateless: true
provider: bridge_provider
custom_authenticators:
- PhpQml\Bridge\SessionAuthenticator
access_control:
- { path: ^/api, roles: ROLE_BRIDGE }

View File

@@ -0,0 +1,11 @@
php_qml_bridge:
resource:
path: ../vendor/php-qml/bridge/src/Controller/
namespace: PhpQml\Bridge\Controller
type: attribute
app_controllers:
resource:
path: ../src/Controller/
namespace: App\Controller
type: attribute

View File

@@ -0,0 +1,11 @@
parameters:
services:
_defaults:
autowire: true
autoconfigure: true
App\:
resource: '../src/'
exclude:
- '../src/Kernel.php'

View File

@@ -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');
}
}

View File

@@ -0,0 +1,9 @@
<?php
use App\Kernel;
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
return function (array $context): Kernel {
return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
};

View File

@@ -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)]);
}
}

View 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);
}
}

View 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;
}
}

View 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;
}