Files
php-qml/docs/makers.md

311 lines
13 KiB
Markdown
Raw Normal View History

# Makers
php-qml ships five [`symfony/maker-bundle`](https://symfony.com/bundles/SymfonyMakerBundle/current/index.html) makers. They generate the cross-side wiring (entity / controller / event / read-model / second window) for the common shapes — that's how a non-trivial app's PHP↔QML glue stays effectively zero.
All of them are invoked from `symfony/`:
```bash
cd symfony
bin/console make:bridge:resource <Name> # CRUD: entity + controller + ReactiveListModel
bin/console make:bridge:command <Name> # non-CRUD action endpoint
bin/console make:bridge:event <Name> # domain event → Mercure → typed QML signal
bin/console make:bridge:read-model <Name> # query-only projection (no Mercure)
bin/console make:bridge:window <Name> # second-window QML scaffold
```
The maker bundle is `require-dev`, so production AppImage builds (which use `composer install --no-dev`) intentionally skip it. Run makers in development; check the output into git.
## `make:bridge:resource`
The headline maker: a Doctrine entity, REST controller, and starter QML list — all three reference each other correctly out of the box.
```bash
bin/console make:bridge:resource Todo
# created: src/Entity/Todo.php
# created: src/Controller/TodoController.php
# created: ../qml/TodoList.qml
```
### Generated files
#### `src/Entity/<Name>.php`
```php
#[ORM\Entity]
#[BridgeResource] // ← marker the Doctrine subscriber looks for
class Todo
{
#[ORM\Id]
#[ORM\Column(type: 'uuid')]
private Uuid $id;
#[ORM\Column(length: 255)]
private string $title = '';
#[ORM\Column(type: 'boolean')]
private bool $done = false;
public function __construct()
{
$this->id = Uuid::v7(); // UUIDv7 — k-sortable + URL-safe
}
// generated getters/setters …
}
```
The `#[BridgeResource]` attribute is what makes the Doctrine subscriber dual-publish on `postPersist` / `postUpdate` / `postRemove`. Nothing else is auto-magic — you can add additional fields, relations, validators, lifecycle callbacks; the subscriber just looks for the attribute.
Pass `--int-id` to swap UUIDv7 for an auto-increment integer:
```bash
bin/console make:bridge:resource Todo --int-id
```
When to use which:
skeleton: bring AppImage parity, scaffolded apps inherit the packaging flow The v0.1.0 shakedown fixes for AppImage assembly (path-repo symlink:false sed, writable-cache redirect) all landed in examples/todo. The skeleton — which is what bin/php-qml-init copies when scaffolding a new app — had no `appimage` target at all, so every scaffolded app would have to either copy the example's Makefile by hand or re-discover the same shakedown bugs. Brings parity: - framework/skeleton/Makefile gains `staging-symfony` and `appimage` targets, mirroring the example's. Two new variables (BUNDLE_SRC, PACKAGING) parameterise the framework-tree paths so bin/php-qml-init can rewrite them at scaffold time without sed-touching the recipe. - framework/skeleton/packaging/skeleton.{desktop,png} added — minimum surface for the AppImage assembly to succeed without the user needing to author them. - framework/skeleton/Makefile's staging-symfony recipe handles both relative (framework default `../../php`) and absolute (post-scaffold) BUNDLE_SRC values via a case statement. - bin/php-qml-init renames packaging/skeleton.* → packaging/$NAME.*, rewrites the .desktop file's Name/Exec/Icon, and updates the Makefile's --app-name / --output / --desktop / --icon flags + BUNDLE_SRC + PACKAGING variables. For --vendor mode, framework's packaging/linux/ is also vendored to .bridge-packaging/ alongside the existing .bridge/ + .bridge-qml/. Verified by scaffolding both modes: - non-vendored: BUNDLE_SRC + PACKAGING absolute paths - --vendor: BUNDLE_SRC=../.bridge, PACKAGING=.bridge-packaging, .bridge-packaging/ contains build-appimage.sh Skeleton's `make quality` still green; staging-symfony works locally (vendor/php-qml/bridge resolves to a real directory, not a symlink). Closes the v0.1.1 follow-up "bin/php-qml-init parity" tracked in PLAN.md §13. Bundled drive-by: docs/makers.md picked up two markdownlint auto-fixes (blank lines around lists) when the IDE saved. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 13:43:48 +02:00
- **UUIDv7 (default)** — distributed-friendly, exposes no insertion-order leak, sortable. Good for anything an end-user might sync between machines.
- **Integer ID** — easier to debug from a shell; smaller wire size; no client-side ID generation needed. Pick this for purely-local single-machine apps.
### `--with-dto` — typed payloads + RFC 7807 errors
Pass `--with-dto` to opt the controller into Symfony's `#[MapRequestPayload]` resolver:
```bash
bin/console make:bridge:resource Todo --with-dto
# created: src/Entity/Todo.php
# created: src/Dto/CreateTodoDto.php
# created: src/Dto/UpdateTodoDto.php
# created: src/Controller/TodoController.php
# created: ../qml/TodoList.qml
```
The generated controller dispatches via the DTOs:
```php
public function create(#[MapRequestPayload] CreateTodoDto $dto): JsonResponse { /* … */ }
public function update(Todo $todo, #[MapRequestPayload] UpdateTodoDto $dto): JsonResponse { /* … */ }
```
What you get for free:
- **Malformed JSON** → 400 `application/problem+json`. No `if (!is_array($data))` boilerplate.
- **Missing required fields / `#[Assert\NotBlank]` violations** → 422 `application/problem+json` with field-by-field detail. `RestClient` parses the response into the `commandFailed` rejection's `problem` arg automatically.
- **No silent type coercion** — `done: "yes"` rejects instead of being cast to true.
- **PATCH semantics** — `Update<Name>Dto` fields default to nullable so callers send only what changed.
Without `--with-dto` the controller still ships and works — the DTO opt-in is for apps that want the RFC 7807 contract end-to-end. The maker fails loud if `symfony/validator` isn't autoloadable; the skeleton + `examples/todo` already require it.
#### `src/Controller/<Name>Controller.php`
CRUD endpoints on `/api/<lowercase-name>`:
| Verb | Path | Behaviour |
| --- | --- | --- |
| `GET` | `/api/todos` | List collection. |
| `POST` | `/api/todos` | Create. |
| `GET` | `/api/todos/<id>` | Fetch single. |
| `PATCH` | `/api/todos/<id>` | Partial update. |
| `DELETE` | `/api/todos/<id>` | Delete. |
The controller injects `EntityManagerInterface` and `NormalizerInterface` (not `SerializerInterface` — Symfony's interface lacks `normalize()`). It uses Symfony's normalizer to JSON-encode entities; `BridgeResource` entities serialise their fields directly.
Routes are picked up automatically — `framework/skeleton/config/routes.yaml` declares the bundle's controller directory as a route resource, so generated controllers light up without further configuration.
#### `qml/<Name>List.qml`
A starter `ListView` driven by a `ReactiveListModel`:
```qml
ReactiveListModel {
id: model
baseUrl: BackendConnection.url
token: BackendConnection.token
source: "/api/todos"
topic: "app://model/todo"
}
ListView {
model: model
delegate: ItemDelegate {
required property string id
required property string title
// … fields per entity
}
}
```
Use it from `Main.qml`:
```qml
import Todo // local QML module declared by your CMakeLists.txt
TodoList { anchors.fill: parent }
```
### After a `resource` maker
```bash
bin/console make:migration
bin/console doctrine:migrations:migrate -n
```
Without that the entity is in the metadata but not in the schema, and the first GET will fail with `no such table`.
### Snapshot test
`framework/php/tests/snapshot/` carries a frozen snapshot of the maker's output for a `Todo` resource. The snapshot test in CI re-runs the maker into a temp dir and diffs against the snapshot — if the maker drifts (e.g. an unrelated change breaks the template), CI catches it.
If you intentionally change the maker, regenerate the snapshot:
```bash
cd framework/php
bin/run-snapshot.sh # see tests/snapshot/run.sh
git add tests/snapshot/
```
## `make:bridge:command`
Generates a controller stub for a non-CRUD action — the kind of thing you'd write a Service Layer or Application Service for in a vanilla Symfony app.
```bash
bin/console make:bridge:command MarkAllDone
# created: src/Controller/MarkAllDoneController.php
```
The generated controller:
```php
#[Route('/api/mark-all-done', methods: ['POST'])]
final class MarkAllDoneController
{
public function __construct(private EntityManagerInterface $em) {}
public function __invoke(Request $request): JsonResponse
{
// TODO: your business logic
$this->em->flush();
return new JsonResponse(['ok' => true]);
}
}
```
Fill in the body. Any `#[BridgeResource]` entities you mutate inside the action publish their Mercure events as usual — multi-row commands reuse the same `correlationKey` (from the request's `Idempotency-Key`), so QML clients see one logical mutation completing.
The route path is derived by kebab-casing the maker name (`MarkAllDone``/api/mark-all-done`). Override it manually if you want a different path.
### When to use a command vs a CRUD endpoint
CRUD covers the 80%. Reach for a command when:
- The verb isn't a primitive (e.g. *publish*, *retry*, *archive*).
- The action affects multiple resources atomically (e.g. *mark all done* across a collection).
- You need request-side validation that doesn't fit the entity (e.g. *only the owner can do this*).
## `make:bridge:event`
Generates a domain-event class, an event subscriber that republishes via `PublisherInterface` on `app://event/<kebab-name>`, and a QML stub that re-emits the wire payload as a typed signal.
```bash
bin/console make:bridge:event ImportFinished
# created: src/Event/ImportFinishedEvent.php
# created: src/EventSubscriber/ImportFinishedSubscriber.php
# created: ../qml/ImportFinishedEventHandler.qml
```
The generated event is a readonly value object — fields are arguments to `__construct`, exposed as readonly properties. The subscriber listens for the event, normalises it to JSON, and publishes through the bundle's `PublisherInterface`. The QML stub instantiates a `MercureClient` on the topic and re-emits the parsed payload as a typed `signal`:
```qml
ImportFinishedEventHandler {
onTriggered: function(payload) {
tray.showMessage("Import finished", `${payload.rowCount} rows`)
}
}
```
Use it from your code by dispatching the event:
```php
public function __invoke(EventDispatcherInterface $dispatcher): void
{
// … import work …
$dispatcher->dispatch(new ImportFinishedEvent(rowCount: $count));
}
```
### When to reach for an event vs a `BridgeResource`
- **Resource changed** (an entity was created / updated / deleted) → `#[BridgeResource]` does the dual-publish for you.
- **Something happened that isn't a resource state change** (background job done, push notification, validation outcome) → `make:bridge:event`. The QML side gets a typed signal instead of trying to derive intent from state diffs.
The split keeps the *what changed* (resource topics) separate from the *what happened* (event topics) so QML subscribers don't have to filter.
## `make:bridge:read-model`
Generates a query-only projection: a query service, a single GET controller, and a `ReactiveListModel`-bound QML stub — deliberately *without* a Mercure topic.
```bash
bin/console make:bridge:read-model OverdueTodos
# created: src/ReadModel/OverdueTodosReadModel.php
# created: src/Controller/OverdueTodosController.php
# created: ../qml/OverdueTodosList.qml
```
| File | Purpose |
| --- | --- |
| `src/ReadModel/<Name>ReadModel.php` | Query service stub. Inject `EntityManagerInterface`; return DTOs/arrays. |
| `src/Controller/<Name>Controller.php` | `GET /api/<kebab-plural>` handler. Forwards to the read-model service. |
| `qml/<Name>List.qml` | `ReactiveListModel` bound to the route. **No `topic`** — read-models aren't auto-reactive. |
Read-models intentionally don't subscribe to a Mercure topic. They're rebuilt on demand (or on a Refresh button) and invalidated by *events*, not by raw entity persistence. To trigger a refresh from the server side, pair this maker with `make:bridge:event` — the QML stub can listen for the event signal and call `model.refresh()`.
### When to use a read-model vs a resource
- **The QML view shows the entity itself** (a row per record, fields map 1:1) → `make:bridge:resource`.
- **The QML view shows a derived projection** (joined tables, aggregates, filtered subsets, denormalised reports) → `make:bridge:read-model`. The query lives in PHP; the QML side just renders.
Read-models are the answer to "I tagged the entity with `#[BridgeResource]` but the list view needs a JOIN" — that's a different shape and shouldn't be force-fit into the dual-publish.
## `make:bridge:window`
Generates a second-window scaffold. Useful when an app is fundamentally multi-window (settings dialog, detail pop-out, second viewport).
```bash
bin/console make:bridge:window Settings
# created: ../qml/SettingsWindow.qml
```
The generated QML window:
skeleton: bring AppImage parity, scaffolded apps inherit the packaging flow The v0.1.0 shakedown fixes for AppImage assembly (path-repo symlink:false sed, writable-cache redirect) all landed in examples/todo. The skeleton — which is what bin/php-qml-init copies when scaffolding a new app — had no `appimage` target at all, so every scaffolded app would have to either copy the example's Makefile by hand or re-discover the same shakedown bugs. Brings parity: - framework/skeleton/Makefile gains `staging-symfony` and `appimage` targets, mirroring the example's. Two new variables (BUNDLE_SRC, PACKAGING) parameterise the framework-tree paths so bin/php-qml-init can rewrite them at scaffold time without sed-touching the recipe. - framework/skeleton/packaging/skeleton.{desktop,png} added — minimum surface for the AppImage assembly to succeed without the user needing to author them. - framework/skeleton/Makefile's staging-symfony recipe handles both relative (framework default `../../php`) and absolute (post-scaffold) BUNDLE_SRC values via a case statement. - bin/php-qml-init renames packaging/skeleton.* → packaging/$NAME.*, rewrites the .desktop file's Name/Exec/Icon, and updates the Makefile's --app-name / --output / --desktop / --icon flags + BUNDLE_SRC + PACKAGING variables. For --vendor mode, framework's packaging/linux/ is also vendored to .bridge-packaging/ alongside the existing .bridge/ + .bridge-qml/. Verified by scaffolding both modes: - non-vendored: BUNDLE_SRC + PACKAGING absolute paths - --vendor: BUNDLE_SRC=../.bridge, PACKAGING=.bridge-packaging, .bridge-packaging/ contains build-appimage.sh Skeleton's `make quality` still green; staging-symfony works locally (vendor/php-qml/bridge resolves to a real directory, not a symlink). Closes the v0.1.1 follow-up "bin/php-qml-init parity" tracked in PLAN.md §13. Bundled drive-by: docs/makers.md picked up two markdownlint auto-fixes (blank lines around lists) when the IDE saved. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 13:43:48 +02:00
- Imports `PhpQml.Bridge`.
- Wraps content in `AppShell` (so it shows the same Reconnecting / Offline chrome as the main window).
- Has its own RestClient + a `Connections { target: SingleInstance ... }` block for launch-arg forwarding.
Open it from your `Main.qml`:
```qml
Component {
id: settingsCmp
SettingsWindow {}
}
Button {
text: "Settings"
onClicked: settingsCmp.createObject().show()
}
```
The window owns its own state — including any `ReactiveListModel`/`ReactiveObject` instances. Two windows of the same app remain coherent through the Mercure dual-publish, not through any inter-window IPC. See [Reactive models §multi-window coherence](update-semantics.md#multi-window-coherence).
## Conventions the makers follow
- **Snake/Pascal/lowercase derivation.** A `Todo` resource produces `Todo` (entity), `TodoController`, `TodoList.qml`, `/api/todos` (lowercased + plural). `MarkAllDone` produces `MarkAllDoneController` and `/api/mark-all-done`. Hyphens in maker names aren't accepted — use PascalCase.
- **Files land where Symfony expects.** Entities under `src/Entity/`, controllers under `src/Controller/`. QML files land under `../qml/` relative to the Symfony app — i.e. the project's QML source tree.
- **Idempotent.** Re-running a maker with the same name is a soft error (file exists). Delete the file first if you want a fresh template.
- **Generated code is yours.** Edit it. The makers don't keep round-tripping through it. They are scaffolders, not source-of-truth generators.
## See also
- [Reactive models](reactive-models.md) — what the generated `<Name>List.qml` plugs into.
- [Update semantics](update-semantics.md) — what `correlationKey` does after the maker's controller `flush()`es.
- [PHP API](php-api.md#bridgeresource-attribute) — `#[BridgeResource]` attribute.