# 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. All three are invoked from `symfony/`: ```bash cd symfony bin/console make:bridge:resource bin/console make:bridge:command bin/console make:bridge:window ``` 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/.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. #### `src/Controller/Controller.php` CRUD endpoints on `/api/`: | Verb | Path | Behaviour | | --- | --- | --- | | `GET` | `/api/todos` | List collection. | | `POST` | `/api/todos` | Create. | | `GET` | `/api/todos/` | Fetch single. | | `PATCH` | `/api/todos/` | Partial update. | | `DELETE` | `/api/todos/` | 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/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: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 `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.