Files
php-qml/docs/php-api.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

8.9 KiB

PHP API reference

Public API exposed by the php-qml/bridge Symfony bundle. Internal services and event subscribers are documented for awareness; you don't usually inject them yourself.

The bundle is registered automatically by framework/skeleton/config/bundles.php:

return [
    PhpQml\Bridge\BridgeBundle::class => ['all' => true],
];

At a glance

Symbol Kind Use it when…
#[BridgeResource] PHP attribute You want a Doctrine entity to dual-publish on Mercure automatically.
ModelPublisher Service You want to publish a custom event without persist/update/remove.
CorrelationContext Service You're inside a non-controller code path and need the current request's Idempotency-Key.
bridge:doctor Console command You want to verify the dev environment is wired correctly.
SessionAuthenticator Security authenticator (Internal) checks the Authorization: Bearer header against BRIDGE_TOKEN.
CorrelationKeyListener Event subscriber (Internal) reads Idempotency-Key into CorrelationContext.

#[BridgeResource] attribute

namespace PhpQml\Bridge\Attribute;

#[\Attribute(\Attribute::TARGET_CLASS)]
final readonly class BridgeResource
{
    public function __construct(public ?string $name = null) {}
}

Tag a Doctrine entity to make its lifecycle events publish to Mercure:

use PhpQml\Bridge\Attribute\BridgeResource;

#[ORM\Entity]
#[BridgeResource]
class Todo { /* … */ }
Attribute arg Default Notes
name Lowercased class basename Used as <name> in app://model/<name> and app://model/<name>/<id> topics.

After tagging, every postPersist / postUpdate / postRemove event triggers a dual-publish via ModelPublisher:

  • app://model/<name> (collection topic — for ReactiveListModel).
  • app://model/<name>/<id> (entity topic — for ReactiveObject).

The maker (make:bridge:resource) attaches this attribute automatically.

Custom resource name

#[BridgeResource(name: 'task')]
class Todo { /* … */ }

Topics become app://model/task and app://model/task/<id>. Useful when the storage class name doesn't match the API noun.

What it doesn't do

  • It doesn't set up the id field, the routes, or the controller. Those come from the maker (or you write them).
  • It doesn't drive serialisation. The Symfony normalizer handles that — by default every public field becomes a JSON property.
  • It doesn't add validation. Use Symfony Validator constraints as you would in any Symfony app.

ModelPublisher

Service that does the dual-publish. Auto-fired by the bundle's Doctrine subscriber on postPersist / postUpdate / postRemove for any #[BridgeResource] entity. Inject it directly when you want to publish a custom event (e.g. progress on a long-running command).

namespace PhpQml\Bridge;

final class ModelPublisher
{
    public function publishEntityChange(object $entity, string $op): void;
}

$op is one of "upsert" / "delete". The published JSON payload:

{
  "op": "upsert",
  "id": "<entity-id>",
  "data": { /* normalised entity */ },
  "correlationKey": "<from CorrelationContext or null>",
  "version": 42
}

version is incremented per resource name. The version table (bridge_resource_version) is migrated automatically.

Example: republish on a custom event

final class MarkAllDoneController
{
    public function __construct(
        private EntityManagerInterface $em,
        private ModelPublisher        $publisher,
    ) {}

    public function __invoke(): JsonResponse
    {
        foreach ($this->em->getRepository(Todo::class)->findAll() as $todo) {
            $todo->setDone(true);
            $this->publisher->publishEntityChange($todo, 'upsert');
        }
        $this->em->flush();
        return new JsonResponse(['ok' => true]);
    }
}

In practice you usually don't need to call publishEntityChange manually — flush() triggers the Doctrine subscriber, which calls it for every changed entity. Direct calls are for cases where the model state isn't in Doctrine (e.g. a read model fed from a cache).


CorrelationContext

Request-scoped service holding the current Idempotency-Key. The bundle's CorrelationKeyListener reads the request header into it; ModelPublisher reads it back when emitting events.

namespace PhpQml\Bridge;

final class CorrelationContext
{
    public function set(?string $key): void;
    public function get(): ?string;
    public function clear(): void;
}

You rarely need to touch this — it auto-plumbs the correlationKey field on every ModelPublisher event. Inject it if you're writing a custom controller that publishes through some other mechanism and wants to thread the same key through.

final class CustomController
{
    public function __invoke(CorrelationContext $ctx, /* … */): JsonResponse
    {
        $key = $ctx->get();   // → "01HX…" (uuid set by the QML client)
        // …
    }
}

bridge:doctor

Console command. Verifies a dev environment is set up correctly.

bin/console bridge:doctor
bin/console bridge:doctor --connect    # also probe BRIDGE_URL

Default checks:

  • Bundle is registered.
  • Mercure URL reachable.
  • BRIDGE_TOKEN configured.
  • MERCURE_JWT_SECRET length is ≥256 bits (lcobucci/jwt's minimum).
  • SQLite database exists / can be created.
  • Doctrine connection works.
  • No pending migrations.

--connect adds:

  • HTTP probe against BRIDGE_URL.
  • Mercure subscribe + publish round-trip with a uuid topic.

Exit code 0 if everything passes, non-zero otherwise. CI runs this as part of the quality target.


Event subscribers

These run automatically; documented for awareness.

BridgeResourceLifecycleSubscriber

Listens to Doctrine's postPersist / postUpdate / postRemove. For each affected entity, checks whether it carries #[BridgeResource]; if so, calls ModelPublisher::publishEntityChange($entity, $op).

To opt out for a specific operation (e.g. you don't want to publish a soft-delete that flips a flag), don't tag the entity with #[BridgeResource] — but then your QML side also stops getting events. Better: keep the attribute, accept the publish; QML clients can ignore events they don't need.

CorrelationKeyListener

Listens to kernel.request. Reads Idempotency-Key from the headers into CorrelationContext. Listens to kernel.terminate to clear the context (request-scoped).

Header is propagated through ModelPublisher regardless of whether the request body actually triggered any persist — so a POST /api/mark-all-done that flushes 100 entities propagates the same Idempotency-Key to all 100 events.


SessionAuthenticator

Custom Symfony Security authenticator wired into framework/skeleton/config/packages/security.yaml. Validates the Authorization: Bearer <token> header against the configured BRIDGE_TOKEN. Anonymous in dev mode; per-session token in bundled mode.

If you want to layer real user authentication on top (e.g. an app that has multiple human users), add a second authenticator higher in the stack — BRIDGE_TOKEN is the transport secret between Qt host and FrankenPHP child, not an end-user credential.


Layout & wiring

framework/php/
├── src/
│   ├── BridgeBundle.php                  bundle registration
│   ├── Attribute/BridgeResource.php
│   ├── ModelPublisher.php                dual-publish + version increment
│   ├── Publisher.php                     thin Mercure facade
│   ├── CorrelationContext.php            request-scoped key holder
│   ├── SessionAuthenticator.php          BRIDGE_TOKEN check
│   ├── EventSubscriber/
│   │   └── CorrelationKeyListener.php    request → context
│   ├── EventListener/                    Doctrine + Symfony listeners
│   ├── Command/
│   │   └── BridgeDoctorCommand.php       bridge:doctor
│   ├── Controller/                       (skeleton route resource lives here)
│   └── Maker/                            symfony/maker-bundle integrations
├── config/services.yaml                  service wiring
└── tests/                                unit + integration + maker snapshot

Service config follows Symfony conventions: autowire on; constructor-injected dependencies; no static state. The bundle has no PHP-side configuration tree at the moment — the bundled FrankenPHP / Caddyfile / env vars supply everything.

See also

  • Update semantics — what correlationKey is and how it interacts with QML rollback.
  • Reactive models — what the QML side does with the events.
  • Makers — what the #[BridgeResource] attribute is generated alongside.