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>
13 KiB
Makers
php-qml ships five symfony/maker-bundle 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/:
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.
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
#[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:
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:
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:
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. Noif (!is_array($data))boilerplate. - Missing required fields /
#[Assert\NotBlank]violations → 422application/problem+jsonwith field-by-field detail.RestClientparses the response into thecommandFailedrejection'sproblemarg automatically. - No silent type coercion —
done: "yes"rejects instead of being cast to true. - PATCH semantics —
Update<Name>Dtofields 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:
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:
import Todo // local QML module declared by your CMakeLists.txt
TodoList { anchors.fill: parent }
After a resource maker
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:
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.
bin/console make:bridge:command MarkAllDone
# created: src/Controller/MarkAllDoneController.php
The generated controller:
#[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.
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:
ImportFinishedEventHandler {
onTriggered: function(payload) {
tray.showMessage("Import finished", `${payload.rowCount} rows`)
}
}
Use it from your code by dispatching the event:
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.
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).
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:
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.
Conventions the makers follow
- Snake/Pascal/lowercase derivation. A
Todoresource producesTodo(entity),TodoController,TodoList.qml,/api/todos(lowercased + plural).MarkAllDoneproducesMarkAllDoneControllerand/api/mark-all-done. Hyphens in maker names aren't accepted — use PascalCase. - Files land where Symfony expects. Entities under
src/Entity/, controllers undersrc/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 — what the generated
<Name>List.qmlplugs into. - Update semantics — what
correlationKeydoes after the maker's controllerflush()es. - PHP API —
#[BridgeResource]attribute.