Files
php-qml/docs/makers.md
magdev beb4e3ab9d docs: refresh README + docs/ for v0.2.0
The README still framed the project as "Phase 5 / pre-v0.1.0" and the
docs predated the v0.2.0 surface (typed BridgeOp, public service
interfaces, port negotiation, pre-migration auto-backup, bridge:export,
periodic auto-update, two new makers, qmltestrunner). Bring them in line
with what's actually shipped, and add badges (release, license, PHP,
Symfony, Qt, FrankenPHP, CI, platform) to the README so the status is
legible at a glance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 22:27:52 +02:00

311 lines
13 KiB
Markdown

# 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:
- **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:
- 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.