Skip to content

The PHP SDK

lemonfiber/sdk-php is the PHP client for lemonfiber’s local HTTP API. It is a peer of the TypeScript SDK: both implement the same specification, and neither defines it. Where this client disagrees with the contract, this client is wrong.

Not yet published. Nothing consumes it, so it stays unreleased until there is a stable major worth pinning; registration on Packagist happens then rather than now. The surface it talks to is lemonfiber ui — one read endpoint for each question a command answers, POST /api/actions/<name> and GET /api/events, on a loopback socket behind a token minted for that run. The envelope sets the endpoints out one by one.

Terminal window
composer require lemonfiber/sdk-php

Requires PHP 8.5. The only runtime dependency is Saloon 4, plus the PSR-7 interfaces it already brings.

lemonfiber prints a token each time it starts. Pass it in; the client sends it as a header and never puts it in an address.

use Lemonfiber\Sdk\Client;
use Lemonfiber\Sdk\Time\Duration;
$client = Client::onPort(9000, $tokenLemonfiberPrinted);
$status = $client->read('/api/status');
$status->kind; // 'status'
$status->data; // the payload, shaped by kind
$client->act('/api/actions/restart', ['forms' => ['tv'], 'services' => ['sonarr']]);

An action’s name and its arguments are the command line’s own. A name this surface does not offer is refused rather than invented, and a field no action takes is refused rather than ignored.

What an envelope holds is shaped by its kind, so it is reached through the kind rather than as an open value. There is one generated class per kind, and it is the way through:

use Lemonfiber\Sdk\Generated\Kind;
use Lemonfiber\Sdk\Generated\LogEnvelope;
$envelope = $client->read('/api/logs'); // Envelope<mixed>
if ($envelope->kind === Kind::Log->value) {
$log = LogEnvelope::in($envelope); // Envelope<the shape the contract gives `log`>
$log->data; // typed by that shape, and checked by static analysis
}

LogEnvelope::in() refuses an envelope carrying any other kind rather than handing back a payload of the wrong shape.

This client was generated from its own copy of the contract, and that copy carries the same kinds the binary this site pins serves, so it has a class for each of fifty-three kinds — the set every payload kind sets out. A copy taken before a kind was added is not a version mismatch: both speak wire version 1, so the client reads every reply, and a kind with no class of its own arrives as an envelope you read the payload of directly.

Live updates arrive as envelopes. Anything gathered before a break in the connection is marked out of date rather than shown as current:

$feed = $client->events(heartbeat: Duration::ofSeconds(15));
foreach ($feed->follow() as $envelope) {
$held = $feed->held()->get('status');
$held?->isStale(); // true once the connection has broken and been reopened
}

Shapes are generated. src/Generated/ holds types produced from web-api.contract.json, the artefact lemonfiber builds from the types it serialises with. Nothing in that directory is edited by hand.

File What it holds
Contract.php The api_version these types were generated from, and the revision they came from
Kind.php Every kind the contract describes
<Kind>Envelope.php One class per kind: the kind it reads, and the payload type the contract gives it

A copy of the artefact is vendored beside the revision it came from, so generation needs no network and a contract change arrives as a diff somebody reads. Three commands, and only the first touches the network:

Command Network What it does
composer contract:sync -- v1.0.0 yes Fetches the artefact at that revision — a release tag or a full commit hash — checks it is one, and vendors it into contract/ beside the revision it came from
composer contract:generate no Writes src/Generated/ from the vendored copy. Deterministic; its output is committed
composer contract:check no Regenerates and fails on any diff, so CI fails on a stale src/Generated

Generation refuses an artefact whose api_version this package does not implement, naming both versions and writing nothing. Contract::API_VERSION comes from the artefact and Api::VERSION comes from that, so the wire version is stated once rather than repeated by hand.

Everything else in src/ is behaviour no schema expresses.

Written by hand What it holds to
Http\RunToken The per-run token travels in a header, never in an address
Http\BaseUrl Loopback only; any other host is refused before anything is sent, and a loopback address is not refused for being named rather than numeric
Envelope\EnvelopeReader A version mismatch is refused plainly, naming both versions, rather than rendering part of an answer
Envelope\Payload An envelope is read as the kind it carries, or not at all
Events\EventStream A stream quiet for twice the agreed heartbeat is reported as broken, not as calm; one missed beat is not
Events\HeldValues Values gathered before a reconnection gap are marked out of date
Exception\RequestFailed A refusal carries the sentence lemonfiber answered with, read back through said(); an answer carrying none names the endpoint and the status instead
Exception\* The error model, in plain language

Every gate is a merge gate, and composer ci runs the eleven below, in this order — cheapest first, so a manifest left unsorted does not wait on a mutation run.

Gate What it asks
composer validate --strict The manifest is well formed, with no warning tolerated
composer normalize --dry-run The manifest is in its canonical order
composer audit No dependency carries a known advisory
composer lint Pint, with the per preset and strict rules on top
composer analyse PHPStan at level max, with 100% type coverage
composer refactor A Rector dry run with zero changes
composer guards The repository’s own: suppressions, oversized files, remote addresses, reasoning in comments
composer deps Every dependency the code uses is declared, and every one declared is used
composer contract:check src/Generated regenerates with no diff
composer test:coverage 100% line coverage
composer test:mutation A 100% mutation score

The one it leaves out is the backward-compatibility check, which runs against the newest v* tag. That is composer bc, a script of its own and a CI job of its own, and it needs a checker installed separately with composer bin bc install. There are no tags yet, so the job skips both its steps and passes having compared nothing — it says as much rather than reporting a success it did not earn.

There is no PHPStan baseline and no ignored errors. @phpstan-ignore, @codeCoverageIgnore, @SuppressWarnings and their relatives are rejected by a guard that reads comments through PHP’s own tokeniser. src/Generated/ is skipped by the linters and the test gates: generated code is proved by regeneration producing no diff, not by passing a linter, and everything that uses it is analysed as usual.

The repository’s own page is sdk-php, and its specification is the sdk-php spec. For the wrapper these classes read, see the envelope; for why Contract::API_VERSION is not the package version, see two version numbers.