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:
21
examples/todo/symfony/.env
Normal file
21
examples/todo/symfony/.env
Normal 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"
|
||||
17
examples/todo/symfony/bin/console
Executable file
17
examples/todo/symfony/bin/console
Executable 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);
|
||||
};
|
||||
46
examples/todo/symfony/composer.json
Normal file
46
examples/todo/symfony/composer.json
Normal 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
5750
examples/todo/symfony/composer.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
11
examples/todo/symfony/config/bundles.php
Normal file
11
examples/todo/symfony/config/bundles.php
Normal 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],
|
||||
];
|
||||
38
examples/todo/symfony/config/packages/doctrine.yaml
Normal file
38
examples/todo/symfony/config/packages/doctrine.yaml
Normal 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
|
||||
@@ -0,0 +1,4 @@
|
||||
doctrine_migrations:
|
||||
migrations_paths:
|
||||
'DoctrineMigrations': '%kernel.project_dir%/migrations'
|
||||
enable_profiler: false
|
||||
13
examples/todo/symfony/config/packages/framework.yaml
Normal file
13
examples/todo/symfony/config/packages/framework.yaml
Normal 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
|
||||
9
examples/todo/symfony/config/packages/mercure.yaml
Normal file
9
examples/todo/symfony/config/packages/mercure.yaml
Normal 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: ['*']
|
||||
18
examples/todo/symfony/config/packages/security.yaml
Normal file
18
examples/todo/symfony/config/packages/security.yaml
Normal 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 }
|
||||
11
examples/todo/symfony/config/routes.yaml
Normal file
11
examples/todo/symfony/config/routes.yaml
Normal 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
|
||||
11
examples/todo/symfony/config/services.yaml
Normal file
11
examples/todo/symfony/config/services.yaml
Normal file
@@ -0,0 +1,11 @@
|
||||
parameters:
|
||||
|
||||
services:
|
||||
_defaults:
|
||||
autowire: true
|
||||
autoconfigure: true
|
||||
|
||||
App\:
|
||||
resource: '../src/'
|
||||
exclude:
|
||||
- '../src/Kernel.php'
|
||||
0
examples/todo/symfony/migrations/.gitkeep
Normal file
0
examples/todo/symfony/migrations/.gitkeep
Normal file
31
examples/todo/symfony/migrations/Version20260502004612.php
Normal file
31
examples/todo/symfony/migrations/Version20260502004612.php
Normal 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');
|
||||
}
|
||||
}
|
||||
9
examples/todo/symfony/public/index.php
Normal file
9
examples/todo/symfony/public/index.php
Normal 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']);
|
||||
};
|
||||
@@ -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