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>
This commit is contained in:
105
docs/makers.md
105
docs/makers.md
@@ -1,14 +1,16 @@
|
||||
# Makers
|
||||
|
||||
php-qml ships three [`symfony/maker-bundle`](https://symfony.com/bundles/SymfonyMakerBundle/current/index.html) makers. They generate the cross-side wiring (entity + controller + QML) for the common shapes — that's how a non-trivial app's PHP↔QML glue stays effectively zero.
|
||||
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 three are invoked from `symfony/`:
|
||||
All of them are invoked from `symfony/`:
|
||||
|
||||
```bash
|
||||
cd symfony
|
||||
bin/console make:bridge:resource <Name>
|
||||
bin/console make:bridge:command <Name>
|
||||
bin/console make:bridge:window <Name>
|
||||
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.
|
||||
@@ -65,6 +67,35 @@ 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>`:
|
||||
@@ -170,6 +201,70 @@ CRUD covers the 80%. Reach for a command when:
|
||||
- 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).
|
||||
|
||||
Reference in New Issue
Block a user