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:
36
examples/todo/Caddyfile
Normal file
36
examples/todo/Caddyfile
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# php-qml framework skeleton — FrankenPHP / Caddy / Mercure config (dev mode).
|
||||||
|
#
|
||||||
|
# Run from the skeleton/symfony/ directory so relative `php_server` paths
|
||||||
|
# resolve correctly: cd framework/skeleton/symfony && frankenphp run --watch
|
||||||
|
# --config ../Caddyfile.
|
||||||
|
{
|
||||||
|
auto_https off
|
||||||
|
admin off
|
||||||
|
frankenphp
|
||||||
|
order php_server before respond
|
||||||
|
order mercure after encode
|
||||||
|
}
|
||||||
|
|
||||||
|
http://127.0.0.1:8765 {
|
||||||
|
root * public/
|
||||||
|
encode gzip
|
||||||
|
|
||||||
|
mercure {
|
||||||
|
transport local
|
||||||
|
# Must match MERCURE_JWT_SECRET in symfony/.env. lcobucci/jwt
|
||||||
|
# requires >= 256 bits, hence the long dev value.
|
||||||
|
publisher_jwt dev_php_qml_bridge_jwt_secret_at_least_256_bits_long_for_lcobucci
|
||||||
|
subscriber_jwt dev_php_qml_bridge_jwt_secret_at_least_256_bits_long_for_lcobucci
|
||||||
|
anonymous
|
||||||
|
publish_origins *
|
||||||
|
cors_origins *
|
||||||
|
}
|
||||||
|
|
||||||
|
php_server
|
||||||
|
|
||||||
|
log {
|
||||||
|
output stderr
|
||||||
|
format console
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
42
examples/todo/Makefile
Normal file
42
examples/todo/Makefile
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# php-qml — Todo example app — Make targets.
|
||||||
|
|
||||||
|
.DEFAULT_GOAL := help
|
||||||
|
|
||||||
|
SYMFONY_DIR := symfony
|
||||||
|
QML_SRC_DIR := qml
|
||||||
|
BUILD_DIR := build/qml
|
||||||
|
QT_BIN := $(BUILD_DIR)/todo
|
||||||
|
|
||||||
|
.PHONY: help
|
||||||
|
help: ## Show available targets
|
||||||
|
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " %-12s %s\n", $$1, $$2}' $(MAKEFILE_LIST)
|
||||||
|
|
||||||
|
.PHONY: install
|
||||||
|
install: ## Install Composer dependencies for the Symfony app
|
||||||
|
cd $(SYMFONY_DIR) && composer install
|
||||||
|
|
||||||
|
.PHONY: build
|
||||||
|
build: ## Build the Qt host
|
||||||
|
cmake -S $(QML_SRC_DIR) -B $(BUILD_DIR) -DCMAKE_BUILD_TYPE=Debug
|
||||||
|
cmake --build $(BUILD_DIR) --parallel
|
||||||
|
|
||||||
|
.PHONY: dev
|
||||||
|
dev: build ## Run the todo app in dev mode (FrankenPHP --watch + Qt host)
|
||||||
|
./scripts/dev.sh
|
||||||
|
|
||||||
|
.PHONY: doctor
|
||||||
|
doctor: ## Run bridge:doctor inside the Symfony app
|
||||||
|
cd $(SYMFONY_DIR) && bin/console bridge:doctor
|
||||||
|
|
||||||
|
.PHONY: doctor-connect
|
||||||
|
doctor-connect: ## Run bridge:doctor with backend connectivity probe
|
||||||
|
cd $(SYMFONY_DIR) && bin/console bridge:doctor --connect
|
||||||
|
|
||||||
|
.PHONY: clean
|
||||||
|
clean: ## Remove build artefacts
|
||||||
|
rm -rf $(BUILD_DIR)
|
||||||
|
|
||||||
|
.PHONY: quality
|
||||||
|
quality: build ## Run PHPStan, php-cs-fixer (check), PHPUnit, qmllint
|
||||||
|
cd ../php && composer quality
|
||||||
|
cmake --build $(BUILD_DIR) --target all_qmllint
|
||||||
81
examples/todo/README.md
Normal file
81
examples/todo/README.md
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
# examples/todo
|
||||||
|
|
||||||
|
The framework's POC application: a real Qt/QML todo app whose backend is a Symfony service generated entirely by the `make:bridge:*` makers. Demonstrates every architectural primitive in PLAN.md §13 Phase 3.
|
||||||
|
|
||||||
|
## What's here that wasn't in the skeleton
|
||||||
|
|
||||||
|
- `Todo` resource (entity + controller + QML snippet) generated via `make:bridge:resource Todo`.
|
||||||
|
- `MarkAllDone` command generated via `make:bridge:command MarkAllDone`, with a body that flips `done = true` on every todo.
|
||||||
|
- `TodoWindow.qml` second-window scaffold generated via `make:bridge:window Todo`, customised to embed a read-only mirror of the same `ReactiveListModel`.
|
||||||
|
- `Main.qml` rewritten as a real list UI with add input, per-row toggle/delete, "mark all done", and "open second window".
|
||||||
|
|
||||||
|
Everything cross-side (PHP ↔ QML) is maker-generated. There is no handwritten bridge glue in this example.
|
||||||
|
|
||||||
|
## Run it
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make install # composer install
|
||||||
|
make build # cmake + qt host
|
||||||
|
make doctor # bridge:doctor — readiness check
|
||||||
|
make dev # FrankenPHP --watch + Qt host
|
||||||
|
```
|
||||||
|
|
||||||
|
Add a few todos, toggle them, click "Mark all done", click "Open second window" — both windows stay in sync.
|
||||||
|
|
||||||
|
## Multi-window test
|
||||||
|
|
||||||
|
1. `make dev` starts the app.
|
||||||
|
2. Add three todos via the input field.
|
||||||
|
3. Click **Open second window**. A `Todos (mirror)` window opens.
|
||||||
|
4. In the main window, toggle one of the todos. The mirror flips its row within ~50 ms (Mercure SSE).
|
||||||
|
5. Add a new todo in the main window. It appears in the mirror within ~50 ms.
|
||||||
|
6. Click **Mark all done** in the main window. Both windows show all rows ticked simultaneously.
|
||||||
|
|
||||||
|
Each window has its own `ReactiveListModel` instance subscribed to the same `app://model/todo` topic; the framework keeps them coherent without per-window glue.
|
||||||
|
|
||||||
|
## Crash-and-recover test
|
||||||
|
|
||||||
|
1. `make dev` is running.
|
||||||
|
2. Add a todo so the list is non-empty.
|
||||||
|
3. From another terminal: `pkill -f 'examples/todo/symfony.*frankenphp'`.
|
||||||
|
4. The app's `AppShell` shows the **Reconnecting** banner (visible on the second window; the main window keeps its own status display).
|
||||||
|
5. Restart the FrankenPHP child: from the example dir, `cd symfony && frankenphp run --watch --config ../Caddyfile` — or just re-run `make dev`.
|
||||||
|
6. The app flips back to **Online** and re-fetches the list. No row corruption.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```text
|
||||||
|
todo/
|
||||||
|
Caddyfile # FrankenPHP / Mercure config (port 8765, anonymous SSE)
|
||||||
|
Makefile # build / dev / doctor / quality
|
||||||
|
scripts/dev.sh
|
||||||
|
symfony/
|
||||||
|
composer.json # path repo points one level deeper at framework/php
|
||||||
|
config/
|
||||||
|
src/Entity/Todo.php # generated by make:bridge:resource
|
||||||
|
src/Controller/TodoController.php # generated, CRUD on /api/todos
|
||||||
|
src/Controller/MarkAllDoneController.php # generated, body filled in
|
||||||
|
public/index.php
|
||||||
|
bin/console
|
||||||
|
.env
|
||||||
|
migrations/
|
||||||
|
qml/
|
||||||
|
CMakeLists.txt
|
||||||
|
main.cpp
|
||||||
|
Main.qml # real todo UI (add / toggle / delete / mark-all / 2nd-window)
|
||||||
|
TodoList.qml # generated; also reused by the second window
|
||||||
|
TodoWindow.qml # generated, customised to embed mirror list
|
||||||
|
```
|
||||||
|
|
||||||
|
## What this example proves
|
||||||
|
|
||||||
|
- The headline workflow scales: a non-trivial app's PHP/QML wiring is **all generated**.
|
||||||
|
- `ReactiveListModel` handles in-flight optimistic mutations (`pending` role drops opacity on toggled rows until the Mercure echo lands).
|
||||||
|
- Two windows of the same QML app stay coherent under mutations from either side.
|
||||||
|
- Stopping FrankenPHP mid-session triggers `Reconnecting` → `Offline` UI; restart restores `Online` and re-fetches without dupes.
|
||||||
|
- `Idempotency-Key` round-trips through to Mercure as `correlationKey` — visible in `dev.log` if you `grep correlationKey`.
|
||||||
|
|
||||||
|
## Out of scope here
|
||||||
|
|
||||||
|
- A CI-driven version of the multi-window / crash-recover tests lives in Phase 3 sub-commit 4 as a bridge-integration suite.
|
||||||
|
- No persistence options (export, backup) — same SQLite `var/data.sqlite` as the skeleton. Apps move to Postgres by overriding `DATABASE_URL`.
|
||||||
35
examples/todo/qml/CMakeLists.txt
Normal file
35
examples/todo/qml/CMakeLists.txt
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.21)
|
||||||
|
project(php_qml_todo_example LANGUAGES CXX)
|
||||||
|
|
||||||
|
set(CMAKE_CXX_STANDARD 20)
|
||||||
|
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||||
|
set(CMAKE_AUTOMOC ON)
|
||||||
|
|
||||||
|
find_package(Qt6 6.5 REQUIRED COMPONENTS Core Gui Quick QuickControls2 Network Qml)
|
||||||
|
qt_standard_project_setup(REQUIRES 6.5)
|
||||||
|
|
||||||
|
# Pull in the framework's QML module (PhpQml.Bridge) — the example uses
|
||||||
|
# the same module the skeleton does, just one level deeper in the tree.
|
||||||
|
add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/../../../framework/qml ${CMAKE_BINARY_DIR}/php_qml_bridge)
|
||||||
|
|
||||||
|
qt_add_executable(todo main.cpp)
|
||||||
|
|
||||||
|
qt_add_qml_module(todo
|
||||||
|
URI Todo
|
||||||
|
VERSION 1.0
|
||||||
|
QML_FILES
|
||||||
|
Main.qml
|
||||||
|
TodoList.qml
|
||||||
|
TodoWindow.qml
|
||||||
|
)
|
||||||
|
|
||||||
|
target_link_libraries(todo PRIVATE
|
||||||
|
Qt6::Core
|
||||||
|
Qt6::Gui
|
||||||
|
Qt6::Quick
|
||||||
|
Qt6::QuickControls2
|
||||||
|
Qt6::Network
|
||||||
|
Qt6::Qml
|
||||||
|
php_qml_bridge
|
||||||
|
php_qml_bridgeplugin
|
||||||
|
)
|
||||||
212
examples/todo/qml/Main.qml
Normal file
212
examples/todo/qml/Main.qml
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import QtQuick.Window
|
||||||
|
|
||||||
|
import PhpQml.Bridge
|
||||||
|
import Todo // local module — picks up TodoList.qml + TodoWindow.qml
|
||||||
|
|
||||||
|
ApplicationWindow {
|
||||||
|
id: window
|
||||||
|
width: 720
|
||||||
|
height: 560
|
||||||
|
visible: true
|
||||||
|
title: "php-qml — Todo example"
|
||||||
|
|
||||||
|
// Single shared model means a second window sees the same data.
|
||||||
|
// (Each ReactiveListModel instance has its own MercureClient
|
||||||
|
// subscription, but the underlying server state is the same.)
|
||||||
|
property alias rest: rest
|
||||||
|
property alias todoModel: todoModel
|
||||||
|
|
||||||
|
RestClient {
|
||||||
|
id: rest
|
||||||
|
baseUrl: BackendConnection.url
|
||||||
|
token: BackendConnection.token
|
||||||
|
}
|
||||||
|
|
||||||
|
ReactiveListModel {
|
||||||
|
id: todoModel
|
||||||
|
baseUrl: BackendConnection.url
|
||||||
|
token: BackendConnection.token
|
||||||
|
source: "/api/todos"
|
||||||
|
topic: "app://model/todo"
|
||||||
|
|
||||||
|
onCommandFailed: function(key, status, problem) {
|
||||||
|
log.append("× failed " + status + ": " + JSON.stringify(problem))
|
||||||
|
}
|
||||||
|
onCommandTimedOut: function(key) {
|
||||||
|
log.append("× timed out " + key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Component {
|
||||||
|
id: secondWindowCmp
|
||||||
|
TodoWindow {}
|
||||||
|
}
|
||||||
|
|
||||||
|
AppShell {
|
||||||
|
anchors.fill: parent
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 16
|
||||||
|
spacing: 12
|
||||||
|
|
||||||
|
// ── Header / actions ────────────────────────────────────
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: "Todos"
|
||||||
|
font.pixelSize: 18
|
||||||
|
font.bold: true
|
||||||
|
}
|
||||||
|
Item { Layout.fillWidth: true }
|
||||||
|
Button {
|
||||||
|
text: "Mark all done"
|
||||||
|
onClicked: {
|
||||||
|
rest.post("/api/mark-all-done").then(function(r) {
|
||||||
|
log.append("→ mark-all-done")
|
||||||
|
}).catch(function(e) {
|
||||||
|
log.append("× mark-all-done " + e.status)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
text: "Open second window"
|
||||||
|
onClicked: {
|
||||||
|
const w = secondWindowCmp.createObject()
|
||||||
|
if (w) w.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Add input ───────────────────────────────────────────
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
TextField {
|
||||||
|
id: newTodoField
|
||||||
|
Layout.fillWidth: true
|
||||||
|
placeholderText: "What needs doing?"
|
||||||
|
onAccepted: addBtn.clicked()
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
id: addBtn
|
||||||
|
text: "Add"
|
||||||
|
enabled: newTodoField.text.trim().length > 0
|
||||||
|
&& BackendConnection.connectionState === BackendConnection.Online
|
||||||
|
onClicked: {
|
||||||
|
const title = newTodoField.text.trim()
|
||||||
|
if (!title) return
|
||||||
|
rest.post("/api/todos", {title: title, done: false})
|
||||||
|
.then(function(r) { log.append("→ added '" + title + "'") })
|
||||||
|
.catch(function(e) { log.append("× add failed " + e.status) })
|
||||||
|
newTodoField.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Todo list ───────────────────────────────────────────
|
||||||
|
Frame {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
padding: 0
|
||||||
|
ListView {
|
||||||
|
id: todoView
|
||||||
|
anchors.fill: parent
|
||||||
|
clip: true
|
||||||
|
model: todoModel
|
||||||
|
spacing: 1
|
||||||
|
|
||||||
|
delegate: ItemDelegate {
|
||||||
|
required property string id
|
||||||
|
required property string title
|
||||||
|
required property bool done
|
||||||
|
required property bool pending
|
||||||
|
|
||||||
|
width: ListView.view.width
|
||||||
|
opacity: pending ? 0.5 : 1.0
|
||||||
|
|
||||||
|
contentItem: RowLayout {
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
CheckBox {
|
||||||
|
checked: done
|
||||||
|
enabled: !pending
|
||||||
|
onToggled: {
|
||||||
|
todoModel.invoke(
|
||||||
|
"PATCH",
|
||||||
|
"/api/todos/" + id,
|
||||||
|
{ done: checked },
|
||||||
|
{ op: "upsert", id: id, data: { id: id, title: title, done: checked } }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Label {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: title
|
||||||
|
font.strikeout: done
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
text: "×"
|
||||||
|
flat: true
|
||||||
|
enabled: !pending
|
||||||
|
onClicked: {
|
||||||
|
todoModel.invoke(
|
||||||
|
"DELETE",
|
||||||
|
"/api/todos/" + id,
|
||||||
|
null,
|
||||||
|
{ op: "delete", id: id }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
visible: todoView.count === 0 && todoModel.ready
|
||||||
|
text: "No todos yet — add one above."
|
||||||
|
opacity: 0.6
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Status / log ────────────────────────────────────────
|
||||||
|
Label {
|
||||||
|
text: BackendConnection.url
|
||||||
|
color: "#888"
|
||||||
|
font.pixelSize: 11
|
||||||
|
elide: Text.ElideRight
|
||||||
|
Layout.fillWidth: true
|
||||||
|
}
|
||||||
|
Frame {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: 100
|
||||||
|
padding: 0
|
||||||
|
ScrollView {
|
||||||
|
anchors.fill: parent
|
||||||
|
TextArea {
|
||||||
|
id: log
|
||||||
|
readOnly: true
|
||||||
|
wrapMode: TextArea.Wrap
|
||||||
|
font.family: "monospace"
|
||||||
|
font.pixelSize: 11
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: SingleInstance
|
||||||
|
function onLaunchArgsReceived(args) {
|
||||||
|
window.requestActivate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
28
examples/todo/qml/TodoList.qml
Normal file
28
examples/todo/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
|
||||||
|
}
|
||||||
|
}
|
||||||
75
examples/todo/qml/TodoWindow.qml
Normal file
75
examples/todo/qml/TodoWindow.qml
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
// Auto-generated by `bin/console make:bridge:window Todo`,
|
||||||
|
// then customised to embed a read-only TodoList. Independently subscribed
|
||||||
|
// to the same Mercure topic — proves the multi-window test in PLAN.md §13.
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import QtQuick.Window
|
||||||
|
import PhpQml.Bridge
|
||||||
|
|
||||||
|
ApplicationWindow {
|
||||||
|
id: todoWindow
|
||||||
|
width: 480
|
||||||
|
height: 520
|
||||||
|
visible: true
|
||||||
|
title: "Todos (mirror)"
|
||||||
|
|
||||||
|
ReactiveListModel {
|
||||||
|
id: mirrorModel
|
||||||
|
baseUrl: BackendConnection.url
|
||||||
|
token: BackendConnection.token
|
||||||
|
source: "/api/todos"
|
||||||
|
topic: "app://model/todo"
|
||||||
|
}
|
||||||
|
|
||||||
|
AppShell {
|
||||||
|
anchors.fill: parent
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 16
|
||||||
|
spacing: 12
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: "Mirror window"
|
||||||
|
font.pixelSize: 16
|
||||||
|
font.bold: true
|
||||||
|
}
|
||||||
|
Label {
|
||||||
|
text: "Read-only view of the same /api/todos. Edits in the main window propagate here within ~50 ms via Mercure."
|
||||||
|
wrapMode: Text.Wrap
|
||||||
|
opacity: 0.7
|
||||||
|
Layout.fillWidth: true
|
||||||
|
}
|
||||||
|
|
||||||
|
Frame {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
padding: 0
|
||||||
|
ListView {
|
||||||
|
anchors.fill: parent
|
||||||
|
clip: true
|
||||||
|
model: mirrorModel
|
||||||
|
|
||||||
|
delegate: ItemDelegate {
|
||||||
|
required property string title
|
||||||
|
required property bool done
|
||||||
|
required property bool pending
|
||||||
|
|
||||||
|
width: ListView.view.width
|
||||||
|
opacity: pending ? 0.5 : 1.0
|
||||||
|
text: (done ? "✓ " : " ") + title
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
visible: parent.count === 0 && mirrorModel.ready
|
||||||
|
text: "(empty)"
|
||||||
|
opacity: 0.5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
28
examples/todo/qml/main.cpp
Normal file
28
examples/todo/qml/main.cpp
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
#include <QGuiApplication>
|
||||||
|
#include <QQmlApplicationEngine>
|
||||||
|
#include <QQmlContext>
|
||||||
|
#include <QStringList>
|
||||||
|
|
||||||
|
#include "SingleInstance.h"
|
||||||
|
|
||||||
|
int main(int argc, char *argv[])
|
||||||
|
{
|
||||||
|
QGuiApplication app(argc, argv);
|
||||||
|
QGuiApplication::setOrganizationName(QStringLiteral("php-qml"));
|
||||||
|
QGuiApplication::setApplicationName(QStringLiteral("todo"));
|
||||||
|
|
||||||
|
PhpQml::Bridge::SingleInstance singleInstance(QStringLiteral("todo"));
|
||||||
|
if (!singleInstance.acquireOrForward(app.arguments())) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
QQmlApplicationEngine engine;
|
||||||
|
engine.rootContext()->setContextProperty(QStringLiteral("SingleInstance"), &singleInstance);
|
||||||
|
engine.loadFromModule("Todo", "Main");
|
||||||
|
|
||||||
|
if (engine.rootObjects().isEmpty()) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return app.exec();
|
||||||
|
}
|
||||||
64
examples/todo/scripts/dev.sh
Executable file
64
examples/todo/scripts/dev.sh
Executable file
@@ -0,0 +1,64 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Start the dev backend (FrankenPHP --watch) and the Qt host together,
|
||||||
|
# tearing both down on Ctrl-C / exit / SIGTERM via process-group kill.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
APP_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
SYMFONY_DIR="$APP_DIR/symfony"
|
||||||
|
QT_BIN="$APP_DIR/build/qml/todo"
|
||||||
|
|
||||||
|
: "${FRANKENPHP:=frankenphp}"
|
||||||
|
: "${BRIDGE_URL:=http://127.0.0.1:8765}"
|
||||||
|
: "${BRIDGE_TOKEN:=devtoken}"
|
||||||
|
|
||||||
|
if [ ! -x "$QT_BIN" ]; then
|
||||||
|
echo "Qt host not built. Run 'make build' first." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! command -v "$FRANKENPHP" >/dev/null 2>&1; then
|
||||||
|
echo "frankenphp not on PATH. Set FRANKENPHP=/path/to/frankenphp or install it." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Explicit PID-based teardown: SIGTERM the children, give them 2s,
|
||||||
|
# then SIGKILL anything still alive. process-group `kill 0` proved
|
||||||
|
# unreliable when FrankenPHP's --watch fork was reparented.
|
||||||
|
FRANKEN_PID=""
|
||||||
|
QT_PID=""
|
||||||
|
cleanup() {
|
||||||
|
trap - EXIT INT TERM
|
||||||
|
for pid in "$QT_PID" "$FRANKEN_PID"; do
|
||||||
|
[ -n "$pid" ] || continue
|
||||||
|
kill -0 "$pid" 2>/dev/null || continue
|
||||||
|
kill -TERM "$pid" 2>/dev/null || true
|
||||||
|
for _ in 1 2 3 4 5 6 7 8 9 10; do
|
||||||
|
kill -0 "$pid" 2>/dev/null || break
|
||||||
|
sleep 0.2
|
||||||
|
done
|
||||||
|
kill -KILL "$pid" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
}
|
||||||
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
|
(cd "$SYMFONY_DIR" && exec "$FRANKENPHP" run --watch --config ../Caddyfile) &
|
||||||
|
FRANKEN_PID=$!
|
||||||
|
echo "frankenphp PID=$FRANKEN_PID"
|
||||||
|
|
||||||
|
# Wait for the backend to come up. Bail if FrankenPHP dies early.
|
||||||
|
for _ in $(seq 1 50); do
|
||||||
|
if curl -fsS -m 1 "$BRIDGE_URL/healthz" >/dev/null 2>&1; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if ! kill -0 "$FRANKEN_PID" 2>/dev/null; then
|
||||||
|
echo "frankenphp died before becoming ready" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 0.2
|
||||||
|
done
|
||||||
|
|
||||||
|
BRIDGE_URL="$BRIDGE_URL" BRIDGE_TOKEN="$BRIDGE_TOKEN" "$QT_BIN" &
|
||||||
|
QT_PID=$!
|
||||||
|
|
||||||
|
wait "$QT_PID"
|
||||||
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