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>
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 — forReactiveListModel).app://model/<name>/<id>(entity topic — forReactiveObject).
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
idfield, 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 typedBridgeOpenum — typo'd ops are caught at compile time instead of silently producing envelopes clients ignore. Migration: replace raw'upsert'/'delete'strings withBridgeOp::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_TOKENconfigured.MERCURE_JWT_SECRETlength 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
1ifDATABASE_URLdoesn'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 withQt.labs.platform.FileDialogso 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
correlationKeyis 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.