Files
php-qml/docs/php-api.md
magdev beb4e3ab9d docs: refresh README + docs/ for v0.2.0
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>
2026-05-03 22:27:52 +02:00

14 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.
BridgeOp Enum You're calling ModelPublisher::publishEntityChange directly.
PublisherInterface / ModelPublisherInterface / CorrelationContextInterface Interfaces You're typehinting bridge services in your own controllers / listeners.
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.
BridgeBundleInfo Value object You want a deep-load canary on the bundle (e.g. a custom /healthz).
bridge:doctor Console command You want to verify the dev environment is wired correctly.
bridge:export Console command You want to copy the active SQLite database to a user-chosen path.
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.

BridgeOp enum

Wire-format enum for op field on every Mercure event the bundle publishes:

namespace PhpQml\Bridge;

enum BridgeOp: string
{
    case Upsert  = 'upsert';
    case Delete  = 'delete';
    case Replace = 'replace';
    case Event   = 'event';
}

The string values are the on-the-wire format — QML clients hardcode them, so renaming a case (without changing its value) is safe; changing a value is a wire-protocol break (and BridgeOpTest will fail the build before it ships).

You only deal with this directly when calling ModelPublisher::publishEntityChange from a custom code path. The Doctrine subscriber, the makers, and the #[BridgeResource] plumbing pick the right case for you.

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.

PublisherInterface

Thin Mercure facade. Concrete implementation: Publisher (autowired). Typehint the interface in app code so a swappable implementation (e.g. an offline-buffer publisher that queues events when Mercure is unreachable) stays non-breaking.

namespace PhpQml\Bridge;

interface PublisherInterface
{
    public function publish(string $topic, array $data): void;
}

Mirrors upstream Symfony's HubInterface/Hub split. Existing call sites that typehint the concrete Publisher class keep working — autowire continues to inject the concrete implementation transparently.

ModelPublisherInterface

The dual-publish surface, one level up from PublisherInterface. Concrete implementation: ModelPublisher.

namespace PhpQml\Bridge;

interface ModelPublisherInterface
{
    public function publishEntityChange(object $entity, BridgeOp $op): void;
}

DoctrineBridgeListener typehints this interface, not the concrete class.

CorrelationContextInterface

Request-scoped key holder; concrete implementation: CorrelationContext. See CorrelationContext for the contract.

namespace PhpQml\Bridge;

interface CorrelationContextInterface
{
    public function set(?string $key): void;
    public function get(): ?string;
    public function clear(): void;
}

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 (or ModelPublisherInterface) directly when you want to publish a custom event (e.g. progress on a long-running command).

namespace PhpQml\Bridge;

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

API break in v0.2.0: the second arg used to be string $op. It is now the typed BridgeOp enum — typo'd ops are caught at compile time instead of silently producing envelopes clients ignore. Migration: replace raw 'upsert' / 'delete' strings with BridgeOp::Upsert / BridgeOp::Delete.

$op is one of the BridgeOp cases. 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 ModelPublisherInterface $publisher,
    ) {}

    public function __invoke(): JsonResponse
    {
        foreach ($this->em->getRepository(Todo::class)->findAll() as $todo) {
            $todo->setDone(true);
            $this->publisher->publishEntityChange($todo, BridgeOp::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. Implements CorrelationContextInterface.

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(CorrelationContextInterface $ctx, /* … */): JsonResponse
    {
        $key = $ctx->get();   // → "01HX…" (uuid set by the QML client)
        // …
    }
}

BridgeBundleInfo

Value object carrying the bundle's name + class FQCN. Used by HealthController as the deep-load canary on /healthz — if the container can construct BridgeBundleInfo, the bundle is wired up correctly.

namespace PhpQml\Bridge;

final readonly class BridgeBundleInfo
{
    public function __construct(
        public string $name,   // 'php-qml/bridge'
        public string $class,  // PhpQml\Bridge\BridgeBundle::class
    ) {}
}

App code rarely injects this directly — but if you're rolling a custom /healthz endpoint and want the same canary semantic without coupling to Publisher (which /healthz used to do pre-v0.2.0), this is the shape to typehint.

/healthz response shape (changed in v0.2.0):

{ "status": "ok", "name": "php-qml/bridge", "bundle": "PhpQml\\Bridge\\BridgeBundle" }

Pre-v0.2.0 the bundle field was PhpQml\Bridge\Publisher. Consumers asserting that exact value need to migrate; consumers reading any-truthy / unknown-keys-ok are unaffected.


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.


bridge:export

Console command. Copies the active SQLite database to a user-chosen path.

bin/console bridge:export /home/me/backup-2026-05-03.sqlite
# → wrote 1245184 bytes to /home/me/backup-2026-05-03.sqlite

Behaviour:

  • Reads the source path from DATABASE_URL. Works in dev and bundled mode without configuration.
  • Overwrites the destination if it exists.
  • Errors with exit code 1 if DATABASE_URL doesn't point at a SQLite file (sqlite:///…), or the source file doesn't exist.
  • Mirrored on the QML side as BackendConnection.exportDatabase(path) — apps typically pair the QML hook with Qt.labs.platform.FileDialog so the user picks a destination natively (see Native dialogs §file pickers).

This is the export half of a "backup my data" UX. The restore half is just cp <backup> <data-dir>/data.sqlite while the app is closed; bundled mode also keeps automatic pre-migration backups for the migration-corruption case.


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 + DI extension
│   ├── BridgeBundleInfo.php              deep-load canary value object
│   ├── BridgeOp.php                      wire-format enum
│   ├── PublisherInterface.php            ─┐
│   ├── ModelPublisherInterface.php        │ public service interfaces
│   ├── CorrelationContextInterface.php   ─┘
│   ├── 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
│   │   └── BridgeExportCommand.php       bridge:export
│   ├── Controller/                       (skeleton route resource lives here)
│   ├── Maker/                            symfony/maker-bundle integrations
│   │   └── Support/                      shared helpers (NameInput, Naming)
│   └── ReadModel/                        (apps' read-model query services land here)
├── 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.