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