Openstatus PHP SDK — Implementation Plan #
Build an official PHP SDK for the Openstatus API, mirroring the ergonomics of @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: <token> - Path shape:
POST /rpc/{package.Service}/{Method}withapplication/jsonbody (Connect RPC's JSON mode). - Error envelope (Connect-style):
{ "code": "not_found", "message": "...", "details": [ ... ] }codeis 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:CheckMonitorService:CreateHTTPMonitor,CreateTCPMonitor,CreateDNSMonitor,UpdateHTTPMonitor,UpdateTCPMonitor,UpdateDNSMonitor,GetMonitor,ListMonitors,TriggerMonitor,DeleteMonitor,GetMonitorStatus,GetMonitorSummary,ListMonitorHTTPResponseLogs,GetMonitorHTTPResponseLogStatusReportService:CreateStatusReport,GetStatusReport,ListStatusReports,UpdateStatusReport,DeleteStatusReport,AddStatusReportUpdateStatusPageService:CreateStatusPage,GetStatusPage,ListStatusPages,UpdateStatusPage,DeleteStatusPage,GetStatusPageContent,GetOverallStatus,AddMonitorComponent,AddStaticComponent,RemoveComponent,UpdateComponent,CreateComponentGroup,UpdateComponentGroup,DeleteComponentGroup,SubscribeToPage,UnsubscribeFromPage,CreatePageSubscription,ListSubscribersMaintenanceService:CreateMaintenance,GetMaintenance,ListMaintenances,UpdateMaintenance,DeleteMaintenanceNotificationService: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/<sdk-version> php/<php-version> |
| 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:
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 namespaceOpenstatus\…\V1\…(e.g.Openstatus\Monitor\V1\CreateHTTPMonitorRequest).api_php/GPBMetadata/Openstatus/**/*.php— descriptor metadata loaded byinitOnce()during message construction.- All classes extend
\Google\Protobuf\Internal\Message, which providesserializeToJsonString()andmergeFromJsonString(). - Enums are emitted as classes with
constints +name()/value()static helpers. Field getters return rawint. int64fields returnint|string(string on 32-bit platforms). We document the quirk in README; no hand-written conversion layer.- No service stubs (
protocolbuffers/phponly 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:
#!/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 #
- Find the latest archive version on
buf.build/openstatus/api/sdks/main:protocolbuffers/php. - Edit
BUF_PHP_VERSIONinscripts/regen.sh. - Run
composer regen. - Commit
scripts/regen.sh+ the resultingsrc/Gen/diff in the same PR. - The weekly
regen.ymlworkflow (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 #
{
"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) #
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:
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 keyOPENSTATUS_API_URL— base URL (defaulthttps://api.openstatus.dev)
8. JSON transport (PSR-18, ~120 LOC) #
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<T> $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/jsonAccept: application/jsonUser-Agent: openstatus-php/<sdk-version> php/<php-version>x-openstatus-key: <api-key>(ifapiKeyset)
9. Service client wrappers (hand-written, one per service) #
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 rawdetailsarray from the envelope (decoded JSON, no further parsing). Empty array when missing.rawBody(): string— the original response body, for debugging.getMessage()— themessagefrom 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,Acceptwhen configured, plus per-call overrides. - JSON body round-trips: serializing a known message and decoding it back produces equivalent state.
- 4xx with
connect.errorbody → correct typed exception withconnectCode,httpStatus,detailspopulated. - Non-JSON 5xx →
ServiceUnavailableExceptionwithrawBodypreserved.
- Transport sends the correct URL (
- 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 onsrc/, excludingsrc/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|stringquirk on int64 fields (timestamps, durations, latencies). - Recipes for Symfony (
services.yamlregistration) and Laravel (AppServiceProviderbinding) — five lines each. - Migration notes from the Node SDK structure to PHP idioms.
13. Release & ops #
- Tag
v0.1.0→ Packagist auto-syncs (onceopenstatus/sdk-phpis registered). .github/workflows/regen.yml: weekly cron that checks Buf for a newer archive version. If one exists, editsBUF_PHP_VERSIONinscripts/regen.sh, runscomposer regen, and opens a PR with both changes. The pin never moves silently — every bump is a reviewable commit.CHANGELOG.mdin Keep a Changelog format. Update on every release.CONTRIBUTING.mddocuments: how to bumpBUF_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 atdocs/decisions.mdpost-v0.1.0 so the rationale (why JSON-over-HTTP, why pinned archive, why mirrored Node shape) survives as a reference.
14. Milestones #
- ✅ Bootstrap —
composer.json,scripts/regen.sh,.editorconfig,.php-cs-fixer.dist.php,phpstan.neon,phpunit.xml.dist, LICENSE, emptyCHANGELOG.md+CONTRIBUTING.md, run regen, commitsrc/Gen/. - ✅ Transport + error mapping —
JsonTransport,ClientOptions,ConnectErrorMapper, typed exception hierarchy, unit tests with PSR-18 mock. - ✅ HealthService end-to-end — smallest service, manual integration check against the real API to validate the wire format.
- ✅ Remaining service wrappers — Monitor → StatusReport → StatusPage → Maintenance → Notification (one PR per service, each with unit tests).
- ✅ Root client + namespace sugar —
OpenstatusClientwith nested->v1->serviceNameaccessors, env-var fallbacks. - ✅ README + examples — full port from Node SDK,
examples/basic.php+examples/create_monitor.php. - ✅ CI —
ci.yml(phpunit unit + phpstan, PHP 8.4),regen.yml(weekly cron). - ✅ 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.