Files
php-qml/docs/php-api.md

358 lines
14 KiB
Markdown
Raw Permalink Normal View History

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