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>
311 lines
13 KiB
Markdown
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.
|