I published turkerdev/php-figma-api in May: a typed PHP client for a third-party REST API. The interesting part is not the client — it is that a client for somebody else’s API is a design problem with no obviously correct answer, and I got it wrong twice before the shape settled.
The symptom
the two available designs, both unsatisfying:
a thin wrapper
every method returns an array. the caller writes
$response['document']['children'][0]['fills'][0]
and there is no type safety anywhere. this is what
most PHP clients for third-party APIs are.
a rich domain model
every response is mapped to typed objects. correct
until the API returns a field you did not model,
or omits one you did, or adds a node type — at
which point the client throws on a payload the
caller could have handled.
the API in question has 14 node types, deeply nested,
with optional fields throughout.Why it happens
A third-party API is a moving target described by documentation that is behind it. Modelling it strictly means the client breaks on changes the caller does not care about; modelling it loosely means the client adds nothing over json_decode.
The fix
The first mistake: one class with every endpoint on it
// v0.1
final class FigmaClient
{
public function getFile(string $key): array {}
public function getFileNodes(string $key, array $ids): array {}
public function getImages(string $key, array $ids, string $format): array {}
public function getComments(string $key): array {}
public function postComment(string $key, string $message, array $at): array {}
// ...and eleven more
}
what goes wrong, in order:
the constructor grows — a token, a base URL, a
client, a request factory, a rate limiter, a cache
each method needs its own optional parameters, and
the images endpoint has seven
testing one endpoint constructs the whole client
a caller who uses two endpoints depends on sixteen
and adding an endpoint is a change to a class every
consumer has a reference toA class with sixteen methods and seven optional parameters on one of them is a class where every signature is a compromise. It works and it is the shape almost every hand-written API client takes, because the alternative looks like more classes than the problem deserves.
A request object per endpoint
interface Request
{
public function toHttpRequest(RequestFactoryInterface $f, UriInterface $base): RequestInterface;
}
final readonly class GetImages implements Request
{
/** @param list<string> $nodeIds */
public function __construct(
public string $fileKey,
public array $nodeIds,
public ImageFormat $format = ImageFormat::Png,
public float $scale = 1.0,
public bool $useAbsoluteBounds = false,
) {}
}
// and the client is one method
$response = $client->send(new GetImages($key, $ids, ImageFormat::Svg));
The seven optional parameters become promoted properties with defaults on a class that exists for one endpoint, which is where they belong. Adding an endpoint is a new file rather than a change to a shared class, and a consumer using two endpoints references two classes rather than one with sixteen methods.
The second mistake: returning value objects
// v0.3 — every response mapped to typed objects
final readonly class Node
{
public function __construct(
public string $id,
public string $name,
public NodeType $type,
public Rectangle $absoluteBoundingBox, // ← required
/** @var list<Node> */
public array $children,
) {}
}
// and the payload that broke it: a node type where
// absoluteBoundingBox is absent, added by the vendor
// in a release with no announcement.
the failure, from a consumer's perspective:
they fetch a file
the client throws on one node in a tree of 4,000
they get nothing
and what they wanted: the 3,999 nodes that parsed, and
an indication that one did not.
a strict type on a third-party payload converts a
partial success into a total failure, which is a
decision the LIBRARY should not be making on the
caller's behalf.The design that worked: typed on request
final readonly class Response
{
public function __construct(
private array $decoded,
private ResponseInterface $raw,
) {}
/** the escape hatch, always available */
public function toArray(): array { return $this->decoded; }
/** the typed view, which the caller opts into */
public function document(): Node
{
return Node::fromArray($this->decoded['document']);
}
/** and the lenient variant, which is the one people use */
public function documentLenient(): PartialTree
{
return PartialTree::fromArray($this->decoded['document']);
}
}
final readonly class PartialTree
{
/** @param list<Node> $nodes */
public function __construct(
public array $nodes,
/** @param list<UnmappedNode> $unmapped */
public array $unmapped,
) {}
}
// 3,999 typed nodes and 1 unmapped, with its raw array
// and the reason it did not map. the caller decides
// whether that is a failure.
Making the strictness a choice at the call site rather than a property of the client is the whole design. A caller who wants a guarantee gets one and accepts the exception; a caller processing a large tree gets what parsed and a list of what did not, which is what almost everybody actually wants.
PSR-18 and PSR-17 at the boundary
public function __construct(
private ClientInterface $http,
private RequestFactoryInterface $requests,
private StreamFactoryInterface $streams,
private string $token,
private UriInterface $baseUri,
) {}
// the package requires psr/http-client and
// psr/http-factory. it does NOT require an
// implementation, which means it composes with
// whatever the consumer already has.
A library that constructs its own HTTP client cannot be given a proxy, a retry policy, a request log or a timeout without an option for each — and it will grow an option for each. Taking the interfaces means the consumer’s existing middleware stack applies, including the logging they already have.
Rate limiting and pagination, which are the API’s semantics
// the API returns 429 with a Retry-After in seconds.
// that is not HTTP's problem, it is this API's, so it
// belongs here rather than in the consumer's client.
if ($response->getStatusCode() === 429) {
throw new RateLimited(
retryAfter: (int) ($response->getHeaderLine('Retry-After') ?: 60),
response: $response,
);
}
// the package does NOT retry. it throws a typed
// exception carrying the delay, and the consumer's
// existing retry middleware decides.
Throwing with the delay attached rather than sleeping is the decision that keeps this composable — a library that sleeps blocks a worker for a duration the consumer did not choose. Everything the API knows and HTTP does not is expressed as a typed exception, and everything HTTP knows is left to the PSR-18 stack.
Testing against a recorded fixture
final class RecordedClient implements ClientInterface
{
public function sendRequest(RequestInterface $r): ResponseInterface
{
$key = sha1($r->getMethod() . $r->getUri()->getPath()
. $r->getUri()->getQuery());
$path = __DIR__ . "/fixtures/{$key}.json";
if (! is_file($path)) {
throw new RuntimeException("unrecorded: {$r->getUri()}");
}
return $this->responseFrom(file_get_contents($path));
}
}
and refreshing them without a live token in CI:
./bin/record --token=$FIGMA_TOKEN --file=abc123
run manually, by a maintainer, when the API changes
the recorded payloads are anonymised by a script:
file names, user names and email addresses replaced
with stable placeholders
CI never has a token. it replays fixtures, and a
request with no fixture is an error rather than a
network call.Throwing on an unrecorded request is what stops a test silently hitting the network, which is the failure mode of every recording library that falls back gracefully. The anonymisation script is the part that made publishing possible — the fixtures are real payloads from a real file and could not have been committed otherwise.
Verifying it worked
$ vendor/bin/phpstan analyse --level=9
[OK] No errors
$ vendor/bin/phpunit
Tests: 188, Assertions: 604
$ composer why-not turkerdev/php-figma-api 1.0
# no conflicts — the only requires are php ^8.3,
# psr/http-client and psr/http-factory
# and the real verification: our own application, which
# uses two endpoints and 40 lines
$ grep -c 'FigmaApi\' src/ -r
2Two references in the consuming application is the measurement that the request-object design achieved what it was for — a consumer using two endpoints depends on two classes. The analyser at level 9 on a package with no framework is easier than on an application and is worth stating as a baseline expectation for something published.
What this costs
A public API surface, and every mistake in it is now somebody else’s problem to work around. The two designs I got wrong were both discovered by using the package myself, which is the cheapest possible way to find them — a third mistake found by a consumer costs a major version and an upgrade guide.
The lenient parsing is also a decision that will age badly in one specific way: a consumer who ignores the unmapped list gets silent data loss rather than an exception, which is the failure mode strict parsing exists to prevent. The documentation says so and documentation is not a mechanism.
And the fixtures are a snapshot of a third-party API that changes without announcement. They will drift, the tests will keep passing, and the first indication will be a consumer reporting a payload the client cannot map — which is the same problem the lenient parsing exists to handle, arriving from the direction the tests cannot see.