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>
358 lines
14 KiB
Markdown
358 lines
14 KiB
Markdown
# 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`:
|
|
|
|
```php
|
|
return [
|
|
PhpQml\Bridge\BridgeBundle::class => ['all' => true],
|
|
];
|
|
```
|
|
|
|
## At a glance
|
|
|
|
| Symbol | Kind | Use it when… |
|
|
| --- | --- | --- |
|
|
| [`#[BridgeResource]`](#bridgeresource-attribute) | PHP attribute | You want a Doctrine entity to dual-publish on Mercure automatically. |
|
|
| [`BridgeOp`](#bridgeop-enum) | Enum | You're calling `ModelPublisher::publishEntityChange` directly. |
|
|
| [`PublisherInterface`](#publisherinterface) / [`ModelPublisherInterface`](#modelpublisherinterface) / [`CorrelationContextInterface`](#correlationcontextinterface) | Interfaces | You're typehinting bridge services in your own controllers / listeners. |
|
|
| [`ModelPublisher`](#modelpublisher) | Service | You want to publish a custom event without persist/update/remove. |
|
|
| [`CorrelationContext`](#correlationcontext) | Service | You're inside a non-controller code path and need the current request's `Idempotency-Key`. |
|
|
| [`BridgeBundleInfo`](#bridgebundleinfo) | Value object | You want a deep-load canary on the bundle (e.g. a custom `/healthz`). |
|
|
| [`bridge:doctor`](#bridgedoctor) | Console command | You want to verify the dev environment is wired correctly. |
|
|
| [`bridge:export`](#bridgeexport) | Console command | You want to copy the active SQLite database to a user-chosen path. |
|
|
| [`SessionAuthenticator`](#sessionauthenticator) | Security authenticator | (Internal) checks the `Authorization: Bearer` header against `BRIDGE_TOKEN`. |
|
|
| [`CorrelationKeyListener`](#correlationkeylistener) | Event subscriber | (Internal) reads `Idempotency-Key` into `CorrelationContext`. |
|
|
|
|
---
|
|
|
|
## `#[BridgeResource]` attribute
|
|
|
|
```php
|
|
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:
|
|
|
|
```php
|
|
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:
|
|
|
|
```php
|
|
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
|
|
|
|
```php
|
|
#[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.
|
|
|
|
```php
|
|
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`.
|
|
|
|
```php
|
|
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`](#correlationcontext) for the contract.
|
|
|
|
```php
|
|
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).
|
|
|
|
```php
|
|
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:
|
|
|
|
```json
|
|
{
|
|
"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
|
|
|
|
```php
|
|
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`](#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.
|
|
|
|
```php
|
|
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.
|
|
|
|
```php
|
|
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):
|
|
|
|
```json
|
|
{ "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.
|
|
|
|
```bash
|
|
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.
|
|
|
|
```bash
|
|
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)`](qml-api.md#exportdatabase) — apps typically pair the QML hook with `Qt.labs.platform.FileDialog` so the user picks a destination natively (see [Native dialogs §file pickers](native-dialogs.md#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](bundled-mode.md#pre-migration-auto-backup) 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](update-semantics.md) — what `correlationKey` is and how it interacts with QML rollback.
|
|
- [Reactive models](reactive-models.md) — what the QML side does with the events.
|
|
- [Makers](makers.md) — what the `#[BridgeResource]` attribute is generated alongside.
|