# Openstatus PHP SDK — Implementation Plan Build an official PHP SDK for the Openstatus API, mirroring the ergonomics of [`@openstatusHQ/sdk-node`](https://github.com/openstatusHQ/sdk-node). **Approach**: pull the pre-generated PHP message classes from Buf's CDN archive, then hand-write a thin **HTTP+JSON** transport on top. No Buf CLI, no OpenAPI generator, no protobuf binary wire format — just JSON over HTTP, type-safe via the generated message classes. ## 1. What the API actually looks like Confirmed by fetching `https://api.openstatus.dev/openapi.yaml`: - **Server**: `https://api.openstatus.dev/` - **Auth**: header `x-openstatus-key: ` - **Path shape**: `POST /rpc/{package.Service}/{Method}` with `application/json` body (Connect RPC's JSON mode). - **Error envelope** (Connect-style): ```json { "code": "not_found", "message": "...", "details": [ ... ] } ``` `code` is one of: `canceled`, `unknown`, `invalid_argument`, `deadline_exceeded`, `not_found`, `already_exists`, `permission_denied`, `resource_exhausted`, `failed_precondition`, `aborted`, `out_of_range`, `unimplemented`, `internal`, `unavailable`, `data_loss`, `unauthenticated`. - **Services & methods** (~52 RPCs total): - `HealthService`: `Check` - `MonitorService`: `CreateHTTPMonitor`, `CreateTCPMonitor`, `CreateDNSMonitor`, `UpdateHTTPMonitor`, `UpdateTCPMonitor`, `UpdateDNSMonitor`, `GetMonitor`, `ListMonitors`, `TriggerMonitor`, `DeleteMonitor`, `GetMonitorStatus`, `GetMonitorSummary`, `ListMonitorHTTPResponseLogs`, `GetMonitorHTTPResponseLog` - `StatusReportService`: `CreateStatusReport`, `GetStatusReport`, `ListStatusReports`, `UpdateStatusReport`, `DeleteStatusReport`, `AddStatusReportUpdate` - `StatusPageService`: `CreateStatusPage`, `GetStatusPage`, `ListStatusPages`, `UpdateStatusPage`, `DeleteStatusPage`, `GetStatusPageContent`, `GetOverallStatus`, `AddMonitorComponent`, `AddStaticComponent`, `RemoveComponent`, `UpdateComponent`, `CreateComponentGroup`, `UpdateComponentGroup`, `DeleteComponentGroup`, `SubscribeToPage`, `UnsubscribeFromPage`, `CreatePageSubscription`, `ListSubscribers` - `MaintenanceService`: `CreateMaintenance`, `GetMaintenance`, `ListMaintenances`, `UpdateMaintenance`, `DeleteMaintenance` - `NotificationService`: `CreateNotification`, `GetNotification`, `ListNotifications`, `UpdateNotification`, `DeleteNotification`, `SendTestNotification`, `CheckNotificationLimit` `HealthService.Check` also accepts `GET` with a `message` query param; we only support `POST` to keep the transport uniform. ## 2. Foundational decisions | Area | Decision | |------------------------|--------------------------------------------------------------------------| | PHP minimum | **8.4** — property hooks, asymmetric visibility, readonly, native enums | | HTTP client | **PSR-18 + PSR-17 factories**, Guzzle shipped as default impl | | Concurrency | **Sync-only** for v0.1 | | Wire format | **JSON only** (`application/json`) | | Default timeout | **30s**, overridable per-client and per-call | | Retries | **None** — users' framework owns retry policy | | User-Agent | `openstatus-php/ php/` | | Strict types | `declare(strict_types=1);` on every hand-written file (enforced via cs-fixer) | | Visibility | All hand-written public classes are `final` | | Versioning | 0.x = breaking changes allowed; commit to SemVer at 1.0 | ## 3. Codegen: pre-built Buf archive (no Buf CLI) Buf hosts a pre-generated zip of the `protocolbuffers/php` output for each pinned schema version. Verified: ```bash curl -fsSL -O https://buf.build/gen/archive/openstatus/api/protocolbuffers/php/v35.0-7d7b7047611f.1.zip ``` What it contains (191 files): - `api_php/Openstatus/**/*.php` — message classes, PHP namespace `Openstatus\…\V1\…` (e.g. `Openstatus\Monitor\V1\CreateHTTPMonitorRequest`). - `api_php/GPBMetadata/Openstatus/**/*.php` — descriptor metadata loaded by `initOnce()` during message construction. - All classes extend `\Google\Protobuf\Internal\Message`, which provides `serializeToJsonString()` and `mergeFromJsonString()`. - Enums are emitted as classes with `const` ints + `name()`/`value()` static helpers. Field getters return raw `int`. - `int64` fields return `int|string` (string on 32-bit platforms). We document the quirk in README; no hand-written conversion layer. - No service stubs (`protocolbuffers/php` only emits messages) — we hand-write per-RPC dispatch. - No `composer.json` — we ship our own. The archive references two transitive metadata initialisers it does not ship: `GPBMetadata\Buf\Validate\Validate` and `GPBMetadata\Gnostic\Openapi\V3\Annotations`. Both are pure descriptor metadata for field annotations; they carry no wire data. We ship empty `initOnce()` stubs at `src/Stubs/GPBMetadataStubs.php` so the runtime can initialise. (The upstream `protocolbuffers/php` plugin cannot compile buf.validate due to a closed enum, so even `include_imports: true` would not solve this.) ### Regen script `scripts/regen.sh` — the archive version is **pinned** in the script. Bumping it is an explicit edit + PR (no CLI override), so every schema change shows up as a reviewable diff: ```bash #!/usr/bin/env bash set -euo pipefail # Pinned Buf-generated PHP archive for buf.build/openstatus/api. # To upgrade: change this line, run `composer regen`, commit the result. readonly BUF_PHP_VERSION="v35.0-7d7b7047611f.1" URL="https://buf.build/gen/archive/openstatus/api/protocolbuffers/php/${BUF_PHP_VERSION}.zip" rm -rf src/Gen mkdir -p src/Gen curl -fsSL "$URL" -o /tmp/openstatus-buf.zip unzip -q /tmp/openstatus-buf.zip -d /tmp/openstatus-buf mv /tmp/openstatus-buf/api_php/Openstatus src/Gen/Openstatus mv /tmp/openstatus-buf/api_php/GPBMetadata src/Gen/GPBMetadata rm -rf /tmp/openstatus-buf /tmp/openstatus-buf.zip echo "${BUF_PHP_VERSION}" > src/Gen/VERSION ``` `src/Gen/VERSION` is committed alongside the generated code so the current pin is always visible. `src/Gen/` itself is also committed so end-users don't need to regen on install. ### Upgrade workflow 1. Find the latest archive version on `buf.build/openstatus/api/sdks/main:protocolbuffers/php`. 2. Edit `BUF_PHP_VERSION` in `scripts/regen.sh`. 3. Run `composer regen`. 4. Commit `scripts/regen.sh` + the resulting `src/Gen/` diff in the same PR. 5. The **weekly** `regen.yml` workflow (see §11) does steps 2–4 automatically when a new pinned version is published. ## 4. Repository layout ``` php-sdk/ ├── docs/decisions.md # this file ├── composer.json ├── phpunit.xml.dist ├── phpstan.neon ├── .php-cs-fixer.dist.php ├── .editorconfig ├── README.md ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE # MIT ├── scripts/ │ └── regen.sh ├── examples/ │ ├── basic.php # port of example.ts (health, list, logs, log detail) │ └── create_monitor.php # full HTTPMonitor with assertions/regions ├── src/ │ ├── Gen/ # buf-generated, committed │ │ ├── Openstatus/ # message classes │ │ ├── GPBMetadata/ # protobuf descriptors │ │ └── VERSION │ ├── OpenstatusClient.php # root client, nested namespaces │ ├── ClientOptions.php # typed value object, named-args ctor │ ├── Transport/ │ │ └── JsonTransport.php # PSR-18-based, POST /rpc/{Service}/{Method}, JSON in/out │ ├── Services/ # hand-written, 6 files │ │ ├── HealthServiceClient.php │ │ ├── MonitorServiceClient.php │ │ ├── StatusReportServiceClient.php │ │ ├── StatusPageServiceClient.php │ │ ├── MaintenanceServiceClient.php │ │ └── NotificationServiceClient.php │ ├── Namespaces/ # `client->monitor->v1` sugar holders, ~12 tiny classes │ │ ├── Monitor.php │ │ ├── MonitorV1.php │ │ └── ... │ ├── Internal/ │ │ └── ConnectErrorMapper.php # PSR-18 ResponseInterface -> typed exception │ └── Exception/ │ ├── OpenstatusException.php # base; carries connectCode, httpStatus, details, rawBody │ ├── AuthenticationException.php │ ├── NotFoundException.php │ ├── InvalidArgumentException.php │ ├── PermissionDeniedException.php │ ├── RateLimitException.php │ └── ServiceUnavailableException.php ├── tests/ │ ├── Unit/ # MockHandler-driven, runs in CI │ └── Integration/ # gated on OPENSTATUS_API_KEY, NOT run in CI └── .github/workflows/ ├── ci.yml # phpunit (unit) + phpstan, PHP 8.4 └── regen.yml # weekly: bump pin + open PR ``` ## 5. `composer.json` ```json { "name": "openstatus/sdk-php", "description": "Official PHP SDK for Openstatus", "license": "MIT", "type": "library", "keywords": ["openstatus", "monitoring", "status-page", "uptime", "sdk"], "require": { "php": ">=8.4", "ext-json": "*", "google/protobuf": "^4.29", "psr/http-client": "^1.0", "psr/http-factory": "^1.0", "psr/http-message": "^2.0", "psr/log": "^3.0", "guzzlehttp/guzzle": "^7.8" }, "require-dev": { "phpunit/phpunit": "^11.3", "phpstan/phpstan": "^1.12", "friendsofphp/php-cs-fixer": "^3.64" }, "autoload": { "psr-4": { "Openstatus\\Sdk\\": "src/", "Openstatus\\": "src/Gen/Openstatus/", "GPBMetadata\\": "src/Gen/GPBMetadata/" } }, "autoload-dev": { "psr-4": { "Openstatus\\Sdk\\Tests\\": "tests/" } }, "scripts": { "regen": "bash scripts/regen.sh", "test": "phpunit", "analyse": "phpstan analyse src tests", "fmt": "php-cs-fixer fix" } } ``` Guzzle is required so `composer require openstatus/sdk-php` works out of the box, but the transport itself depends only on the PSR interfaces — users in Symfony/Laravel apps can swap in their own PSR-18 client. ## 6. `ClientOptions` (typed value object) ```php namespace Openstatus\Sdk; use Psr\Http\Client\ClientInterface; use Psr\Http\Message\RequestFactoryInterface; use Psr\Http\Message\StreamFactoryInterface; use Psr\Log\LoggerInterface; final class ClientOptions { public function __construct( public readonly ?string $apiKey = null, // falls back to OPENSTATUS_API_KEY public readonly ?string $baseUrl = null, // falls back to OPENSTATUS_API_URL, default https://api.openstatus.dev public readonly float $timeout = 30.0, // seconds public readonly ?ClientInterface $httpClient = null, public readonly ?RequestFactoryInterface $requestFactory = null, public readonly ?StreamFactoryInterface $streamFactory = null, public readonly ?LoggerInterface $logger = null, // PSR-3, request/response logged at DEBUG ) {} } ``` ## 7. Root client & namespace sugar Mirrors the Node SDK shape: `client.monitor.v1.MonitorService.listMonitors(...)`. Service property is **camelCase** (PHP convention) while everything else matches Node: ```php use Openstatus\Sdk\OpenstatusClient; use Openstatus\Sdk\ClientOptions; use Openstatus\Monitor\V1\ListMonitorsRequest; $client = new OpenstatusClient(new ClientOptions( apiKey: getenv('OPENSTATUS_API_KEY'), )); $res = $client->monitor->v1->monitorService->listMonitors(new ListMonitorsRequest()); ``` `OpenstatusClient` constructs the `JsonTransport` once, then builds nested `monitor`/`health`/`statusReport`/`statusPage`/`maintenance`/`notification` holders. Each holder has a `->v1` property pointing to a holder that exposes the typed service client. Total sugar code: ~12 tiny `final` classes (~5 lines each, leveraging PHP 8.4 property hooks where useful). Env fallbacks match the Node SDK: - `OPENSTATUS_API_KEY` — API key - `OPENSTATUS_API_URL` — base URL (default `https://api.openstatus.dev`) ## 8. JSON transport (PSR-18, ~120 LOC) ```php namespace Openstatus\Sdk\Transport; use Google\Protobuf\Internal\Message; use Psr\Http\Client\ClientInterface; use Psr\Http\Message\RequestFactoryInterface; use Psr\Http\Message\StreamFactoryInterface; use Psr\Log\LoggerInterface; use Openstatus\Sdk\Internal\ConnectErrorMapper; final class JsonTransport { public function __construct( private readonly ClientInterface $http, private readonly RequestFactoryInterface $requestFactory, private readonly StreamFactoryInterface $streamFactory, private readonly string $baseUrl, private readonly array $defaultHeaders, private readonly ?LoggerInterface $logger = null, ) {} /** * @template T of Message * @param class-string $responseClass * @return T */ public function call( string $serviceFqn, string $method, Message $request, string $responseClass, array $extraHeaders = [], ): Message { $url = rtrim($this->baseUrl, '/') . '/rpc/' . $serviceFqn . '/' . $method; $body = $this->streamFactory->createStream($request->serializeToJsonString()); $req = $this->requestFactory->createRequest('POST', $url)->withBody($body); foreach (array_merge($this->defaultHeaders, $extraHeaders) as $name => $value) { $req = $req->withHeader($name, $value); } $started = microtime(true); $res = $this->http->sendRequest($req); $this->logger?->debug('openstatus.rpc', [ 'service' => $serviceFqn, 'method' => $method, 'status' => $res->getStatusCode(), 'ms' => (int) ((microtime(true) - $started) * 1000), ]); if ($res->getStatusCode() >= 400) { ConnectErrorMapper::rethrow($res); } $response = new $responseClass(); $response->mergeFromJsonString((string) $res->getBody()); return $response; } } ``` Default headers set by `OpenstatusClient`: - `Content-Type: application/json` - `Accept: application/json` - `User-Agent: openstatus-php/ php/` - `x-openstatus-key: ` (if `apiKey` set) ## 9. Service client wrappers (hand-written, one per service) ```php namespace Openstatus\Sdk\Services; use Openstatus\Monitor\V1 as M; use Openstatus\Sdk\Transport\JsonTransport; final class MonitorServiceClient { private const FQN = 'openstatus.monitor.v1.MonitorService'; public function __construct(private readonly JsonTransport $transport) {} public function listMonitors( M\ListMonitorsRequest $req, array $headers = [], ): M\ListMonitorsResponse { return $this->transport->call(self::FQN, 'ListMonitors', $req, M\ListMonitorsResponse::class, $headers); } public function createHTTPMonitor( M\CreateHTTPMonitorRequest $req, array $headers = [], ): M\CreateHTTPMonitorResponse { return $this->transport->call(self::FQN, 'CreateHTTPMonitor', $req, M\CreateHTTPMonitorResponse::class, $headers); } // ... one method per RPC in this service } ``` ~52 methods across 6 files. Mechanical, easy to review, easy for contributors to add when the schema gains a new RPC. Method names are camelCase (PHP convention); per-call header overrides via optional `array $headers = []` second argument. ## 10. Error mapping `ConnectErrorMapper` decodes a PSR-7 4xx/5xx `ResponseInterface` and throws a typed subclass. | Connect `code` | PHP exception | |-----------------------|--------------------------------| | `unauthenticated` | `AuthenticationException` | | `not_found` | `NotFoundException` | | `invalid_argument` | `InvalidArgumentException` | | `permission_denied` | `PermissionDeniedException` | | `resource_exhausted` | `RateLimitException` | | `unavailable` | `ServiceUnavailableException` | | anything else | `OpenstatusException` | When the response body is not a valid `connect.error` envelope (e.g. 502 HTML from a proxy), throw `ServiceUnavailableException` with the raw body preserved. `OpenstatusException` carries: - `connectCode(): string` — the Connect code (e.g. `not_found`), or `'unknown'` when the envelope is missing. - `httpStatus(): int` — the raw HTTP status code. - `details(): array` — the raw `details` array from the envelope (decoded JSON, no further parsing). Empty array when missing. - `rawBody(): string` — the original response body, for debugging. - `getMessage()` — the `message` from the envelope, or the HTTP reason phrase as fallback. ## 11. Testing - **Unit tests** (PSR-18 mock client, runs on every PR): - Transport sends the correct URL (`/rpc/openstatus.monitor.v1.MonitorService/ListMonitors`). - Headers include `x-openstatus-key`, `User-Agent`, `Content-Type`, `Accept` when configured, plus per-call overrides. - JSON body round-trips: serializing a known message and decoding it back produces equivalent state. - 4xx with `connect.error` body → correct typed exception with `connectCode`, `httpStatus`, `details` populated. - Non-JSON 5xx → `ServiceUnavailableException` with `rawBody` preserved. - **Integration tests** (gated on `OPENSTATUS_API_KEY`, run **locally** by maintainers before tagging a release; **not** wired into CI): - `HealthService.Check` — proves the wire format works against the real API. - `MonitorService.ListMonitors` — proves auth + real JSON decode. - **CI matrix**: PHP **8.4 only** (until 8.5 ships). Steps: `composer fmt -- --dry-run` + `composer analyse` (phpstan **level 8** on `src/`, excluding `src/Gen/`) + `composer test`. ## 12. README & examples Full port of the Node SDK README — same TOC, per-service sections, one example per RPC, error-handling section, regions/enums reference. Users compare SDKs by README completeness; parity matters. `examples/basic.php` mirrors `example.ts`: health check, list monitors, list HTTP response logs, fetch one log detail. `examples/create_monitor.php` builds a full `HTTPMonitor` with `Periodicity`, `Region`, `HTTPMethod`, and a `StatusCodeAssertion` — the hardest first-time task, worth a worked example. README also documents: - The `int|string` quirk on int64 fields (timestamps, durations, latencies). - Recipes for Symfony (`services.yaml` registration) and Laravel (`AppServiceProvider` binding) — five lines each. - Migration notes from the Node SDK structure to PHP idioms. ## 13. Release & ops - Tag `v0.1.0` → Packagist auto-syncs (once `openstatus/sdk-php` is registered). - `.github/workflows/regen.yml`: **weekly cron** that checks Buf for a newer archive version. If one exists, edits `BUF_PHP_VERSION` in `scripts/regen.sh`, runs `composer regen`, and opens a PR with both changes. The pin never moves silently — every bump is a reviewable commit. - `CHANGELOG.md` in [Keep a Changelog](https://keepachangelog.com/) format. Update on every release. - `CONTRIBUTING.md` documents: how to bump `BUF_PHP_VERSION`, how to run tests (`composer test`, `composer analyse`, `composer fmt`), code style rules (PSR-12 + `declare(strict_types=1);`), PR conventions. - This document was the original `plan.md`; it lives at `docs/decisions.md` post-v0.1.0 so the rationale (why JSON-over-HTTP, why pinned archive, why mirrored Node shape) survives as a reference. ## 14. Milestones 1. ✅ **Bootstrap** — `composer.json`, `scripts/regen.sh`, `.editorconfig`, `.php-cs-fixer.dist.php`, `phpstan.neon`, `phpunit.xml.dist`, LICENSE, empty `CHANGELOG.md` + `CONTRIBUTING.md`, run regen, commit `src/Gen/`. 2. ✅ **Transport + error mapping** — `JsonTransport`, `ClientOptions`, `ConnectErrorMapper`, typed exception hierarchy, unit tests with PSR-18 mock. 3. ✅ **HealthService end-to-end** — smallest service, manual integration check against the real API to validate the wire format. 4. ✅ **Remaining service wrappers** — Monitor → StatusReport → StatusPage → Maintenance → Notification (one PR per service, each with unit tests). 5. ✅ **Root client + namespace sugar** — `OpenstatusClient` with nested `->v1->serviceName` accessors, env-var fallbacks. 6. ✅ **README + examples** — full port from Node SDK, `examples/basic.php` + `examples/create_monitor.php`. 7. ✅ **CI** — `ci.yml` (phpunit unit + phpstan, PHP 8.4), `regen.yml` (weekly cron). 8. ✅ **v0.1.0 release prep** on Packagist + move `plan.md` → `docs/decisions.md`. ## 15. Trade-offs vs alternatives | Option | Codegen step | Type safety | Service stubs | Wire format | |---------------------------------|------------------------|-------------------|---------------|-------------| | **Buf archive + JSON (chosen)** | curl + unzip | ✅ (protobuf msgs) | ❌ hand-write | JSON | | Buf CLI + `protoc-gen-php` | local `buf generate` | ✅ | ❌ hand-write | JSON or bin | | OpenAPI Generator | `npx openapi-gen` | ✅ | ✅ generated | JSON | | Hand-written + arrays | none | ❌ | ❌ hand-write | JSON | The archive approach is the leanest: no toolchain dependency for contributors (just `curl`), type-safe message classes that match the proto source of truth byte-for-byte, and only ~120 lines of transport + ~52 one-liners of service dispatch to maintain ourselves.