Files
php-qml/docs/makers.md
magdev da048434b8 docs: rewrite README + add comprehensive docs/
README is now tight and link-heavy: 60-second tour, then deep links
into docs/. The wall of detail moved out.

docs/ covers the framework end-to-end:
- getting-started.md — prerequisites by distro (Tumbleweed, Fedora,
  Debian/Ubuntu, Arch), full first-run walkthrough, troubleshooting.
- architecture.md — process pair, transport, dev/bundled mode.
- update-semantics.md — state machine + optimistic mutations + key
  round-tripping.
- reactive-models.md — ReactiveListModel, ReactiveObject, Mercure
  dual-publish.
- makers.md — make:bridge:resource/command/window.
- dev-workflow.md — hot reload (PHP + QML), dev console, editor
  configs, bridge:doctor, snapshot/integration test loops, perfsmoke.
- bundled-mode.md — supervisor, per-session secret rotation,
  first-launch migrations, auto-update wiring.
- packaging-linux.md — make appimage, build-appimage.sh CLI,
  AppImageUpdate sidecar, perfsmoke budgets, release CI, bundle-size
  breakdown.
- qml-api.md / php-api.md — exhaustive symbol reference with all
  Q_PROPERTY/Q_INVOKABLE/signals + every public PHP service / attribute
  / command.
- configuration.md — every env var (host, Symfony, dev script,
  packaging script, perfsmoke), every CLI flag (php-qml-init,
  build-appimage.sh), make targets, default ports/paths.

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

7.7 KiB

Makers

php-qml ships three symfony/maker-bundle 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/:

cd symfony
bin/console make:bridge:resource <Name>
bin/console make:bridge:command  <Name>
bin/console make:bridge:window   <Name>

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.

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: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 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 — what the generated <Name>List.qml plugs into.
  • Update semantics — what correlationKey does after the maker's controller flush()es.
  • PHP API#[BridgeResource] attribute.