Files
php-qml/docs/makers.md
magdev 341bcacafe skeleton: bring AppImage parity, scaffolded apps inherit the packaging flow
The v0.1.0 shakedown fixes for AppImage assembly (path-repo
symlink:false sed, writable-cache redirect) all landed in
examples/todo. The skeleton — which is what bin/php-qml-init copies
when scaffolding a new app — had no `appimage` target at all, so every
scaffolded app would have to either copy the example's Makefile by
hand or re-discover the same shakedown bugs.

Brings parity:

  - framework/skeleton/Makefile gains `staging-symfony` and `appimage`
    targets, mirroring the example's. Two new variables (BUNDLE_SRC,
    PACKAGING) parameterise the framework-tree paths so bin/php-qml-init
    can rewrite them at scaffold time without sed-touching the recipe.
  - framework/skeleton/packaging/skeleton.{desktop,png} added — minimum
    surface for the AppImage assembly to succeed without the user
    needing to author them.
  - framework/skeleton/Makefile's staging-symfony recipe handles both
    relative (framework default `../../php`) and absolute (post-scaffold)
    BUNDLE_SRC values via a case statement.
  - bin/php-qml-init renames packaging/skeleton.* → packaging/$NAME.*,
    rewrites the .desktop file's Name/Exec/Icon, and updates the
    Makefile's --app-name / --output / --desktop / --icon flags +
    BUNDLE_SRC + PACKAGING variables. For --vendor mode, framework's
    packaging/linux/ is also vendored to .bridge-packaging/ alongside
    the existing .bridge/ + .bridge-qml/.

Verified by scaffolding both modes:
  - non-vendored: BUNDLE_SRC + PACKAGING absolute paths
  - --vendor: BUNDLE_SRC=../.bridge, PACKAGING=.bridge-packaging,
    .bridge-packaging/ contains build-appimage.sh

Skeleton's `make quality` still green; staging-symfony works locally
(vendor/php-qml/bridge resolves to a real directory, not a symlink).

Closes the v0.1.1 follow-up "bin/php-qml-init parity" tracked in
PLAN.md §13.

Bundled drive-by: docs/makers.md picked up two markdownlint auto-fixes
(blank lines around lists) when the IDE saved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 13:43:48 +02:00

7.8 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.