hampel / binarylane-api
A PHP client for the BinaryLane API - servers, server actions, DNS, load balancers, VPCs and billing - over any PSR-18 HTTP client
Requires
- php: >=8.3
- ext-ctype: *
- ext-filter: *
- ext-json: *
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
- psr/http-message: ^1.1|^2.0
- psr/log: ^1.1|^2.0|^3.0
Requires (Dev)
- guzzlehttp/guzzle: ^7.8|^8.0
- hampel/rig: ^1.1
- laravel/pint: ^1.30
- phpstan/phpstan: ^2.1.22
- phpunit/phpunit: ^12.0
Suggests
- guzzlehttp/guzzle: A PSR-18 client to send requests with; any implementation will do
- guzzlehttp/psr7: PSR-17 factories, found automatically when none are passed to Client - as are nyholm/psr7 and laminas/laminas-diactoros
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-13 00:12:49 UTC
README
By Simon Hampel
A PHP client for the BinaryLane API, built on PSR-18. Complete coverage of the v2 API: servers and all forty-two server actions, DNS, images, load balancers, VPCs, SSH keys, billing and performance data.
Installation
composer require hampel/binarylane-api
You also need a PSR-18 client and a PSR-17 factory. Guzzle provides both, 7 or 8:
composer require guzzlehttp/guzzle
Usage
use GuzzleHttp\Client as Guzzle; use GuzzleHttp\Psr7\HttpFactory; use Hampel\BinaryLane\Api\Client; $binarylane = Client::withToken('MY-API-TOKEN', new Guzzle()); $binarylane->verify(); // does this token work? foreach ($binarylane->servers()->each() as $server) { echo $server->describe(), "\n"; }
withToken() finds a PSR-17 factory for you — Guzzle's, Nyholm's or Diactoros', whichever is
installed. The long form names everything:
use Hampel\BinaryLane\Api\Authentication\ApiToken; use Hampel\BinaryLane\Api\Config; $factory = new HttpFactory(); // PSR-17, fills both the request and stream roles $binarylane = new Client( new Config(perPage: 200), new ApiToken('MY-API-TOKEN'), new Guzzle(), $factory, $factory, $logger, // PSR-3, optional );
Requests are logged at debug, failures at error. The token is never logged: ApiToken
keeps it out of __toString(), var_dump() and stack traces.
The three things to know before writing anything
1. Nearly every mutation answers with an action, not a result
Powering a server on, resizing it, rebuilding it, taking a backup, creating a server — none of
those have finished when the call returns. What comes back is an Action with an id you can
ask about again.
$action = $binarylane->serverActions()->powerOn(1234); $binarylane->actions()->await($action); // raises if it failed
await() is the loop, and it exists because writing it correctly means knowing the three ways
an action stops without completing:
| what happened | how it is reported | exception |
|---|---|---|
| it errored | status errored |
ActionFailedException |
| it is asking a question | still in-progress, indefinitely |
ActionBlockedException |
| an unpaid invoice is blocking it | still in-progress, indefinitely |
ActionBlockedException |
| your deadline passed | still running — nothing is cancelled | ActionTimedOutException |
A while ($action->status !== 'completed') loop never escapes the middle two.
Do not read reason as an explanation. It narrates what was being attempted and reads the
same whether the action worked or not — an errored uptime action carries "Your server uptime
is being checked". Action::failureReason() reads error_message instead, which the
specification does not declare on an action and the API returns anyway, and answers null rather
than something misleading when there is nothing to say.
Some actions can also be answered with 202 and no body at all, in which case there is no
action to wait on. Every method on ServerActions returns ?Action for that reason, and
Servers::actions($id) is where to look for what a null started.
2. Token scope: there isn't any
BinaryLane issues one kind of API token — no scopes, no expiry, no read-only variant. A token given to a monitoring job is a token that could cancel a server. The separation has to be on your side.
3. A 404 does not mean deleted
An object belonging to another account answers 404, not 403. So find() returning null
means "not visible to this token", which is wider than "gone".
Failures
Everything this package throws implements Hampel\BinaryLane\Api\Exception\ExceptionInterface.
use Hampel\BinaryLane\Api\Exception\NotAuthenticatedException; use Hampel\BinaryLane\Api\Exception\ValidationException; try { $binarylane->servers()->create($request); } catch (ValidationException $e) { foreach ($e->fieldErrors() as $field => $messages) { echo $field, ': ', implode('; ', $messages), "\n"; } } catch (NotAuthenticatedException $e) { // the token is not usable }
| status | exception | notes |
|---|---|---|
| 400 | ValidationException |
the only status carrying errors, keyed by JSON property name |
| 401 | NotAuthenticatedException |
carries no body at all |
| 403 | NotPermittedException |
declared on exactly one operation — image download |
| 404 | NotFoundException |
also means "belongs to someone else" |
| 429 | TooManyRequestsException |
not in the specification; mapped defensively |
| 5xx | ServerException |
|
| other 4xx | ClientException |
|
| 2xx this package cannot act on | MalformedResponseException |
not JSON, an empty body on any status but 202/204, or a body missing its envelope key — a proxy page read as an empty list is the accident this prevents |
| never answered | RequestException |
DNS, TLS, timeout — the only one worth retrying blindly |
An action that fails raises ActionFailedException, ActionBlockedException or
ActionTimedOutException, none of which are ApiException — every request succeeded; the
work did not.
Pagination
Lists come back as a Page, which counts, iterates and serialises:
$page = $binarylane->servers()->list(perPage: 200); $page->total; // how many exist across every page count($page); // how many are on this one $page->hasMore();
each() walks every page as far as it is consumed, following the API's own next link — so
stopping early stops making requests:
foreach ($binarylane->servers()->each() as $server) { if ($server->isUnderMaintenance) { break; // no further requests are made } }
count() asks for the total without fetching anything:
$binarylane->servers()->count(); // one request, no items
The default page size is 20, which is smaller than most people expect. A list that looks complete is often the first twenty of several hundred.
Servers
$server = $binarylane->servers()->get(1234); $server->publicAddress(); // null is legitimate - see below $server->isInService(); // provisioned and paid for - NOT "powered on" $server->isActionable(); // false under maintenance, or while building $server->selectedSizeOptions?->memory; // what THIS server has, in MB
status does not tell you whether a server is powered on. A server that was genuinely off
reported active, exactly as its running neighbours did, and nothing else in the payload
carries a power state. That is why there is no isRunning() on Server — there was one, and
it was wrong on precisely the server it mattered for. Ask the API instead:
$binarylane->serverActions()->checkRunning(1234); // bool $binarylane->serverActions()->checkUptime(1234); // "0 days, 0:02", or null
A question-shaped action answers by completing or erroring, not in its payload. Measured
both ways: is_running completes with a null result_data when the server is up and errors
when it is down. Since await() raises on an errored action, asking the obvious way throws
when the answer is simply "no" — which is what checkRunning() and ask() exist to avoid.
$action = $binarylane->serverActions()->ask(1234, 'is_running'); // ?Action; null means "no"
Read selectedSizeOptions rather than size for what a server actually has. The size is the
catalogue entry it was created from; the two disagree on any server that has ever been given
extra memory, disk, transfer or addresses.
A server can legitimately have no public address at all — declining IPv4 is a discounted option, and a VPC-only server may have nothing routable.
Creating one
use Hampel\BinaryLane\Api\Request\CreateServer; use Hampel\BinaryLane\Api\Request\SizeOptions; $created = $binarylane->servers()->create( CreateServer::of('std-2vcpu', 'ubuntu-24-04-lts', 'syd') ->withName('vps01.example.com') ->withSshKeys([12345]) ->withOptions(SizeOptions::none()->withMemory(8192)->withDailyBackups(7)) ); $binarylane->actions()->awaitAll($created->actionIds(), timeout: 1800); $server = $binarylane->servers()->get($created->id());
Four defaults are decided for you by leaving a field unset, and each surprises somebody:
- no name — a random hostname is generated;
- no password — one is generated and emailed to the account address, not returned;
- no SSH keys — every key marked default on the account is deployed.
withoutSshKeys()sends an empty array, which deploys none. Null and empty are opposite answers; - port blocking on — outgoing TCP 22, 25 and 3389 are blocked. Turning it off needs a verified account.
validateAgainst($image, $size) checks the request against what the image and size actually
permit, so the mismatch is named rather than arriving as a 400 about a field:
CreateServer::for($size, $image, 'syd')->validateAgainst($image, $size);
Server actions
All forty-two, typed:
$binarylane->serverActions()->powerOn(1234); $binarylane->serverActions()->rename(1234, 'vps02.example.com'); $binarylane->serverActions()->changeKernel(1234, 7); $binarylane->serverActions()->addDisk(1234, 50, 'Data');
Four of them destroy data with no confirmation, in BinaryLane's own words:
rebuild()andrestore()discard the server's disks;cloneUsingBackup()is addressed to the source server and overwrites the target;resize()when it carries an image change, or reduces a retained backup count.
That last one is the quiet one — reducing weeklyBackups to 0 deletes every weekly backup the
server has. Request\Resize will tell you what a particular request would do:
use Hampel\BinaryLane\Api\Request\Resize; use Hampel\BinaryLane\Api\Request\TakeBackup; use Hampel\BinaryLane\Api\Enum\BackupSlot; $resize = Resize::toSize('std-4vcpu') ->withOptions(SizeOptions::none()->withWeeklyBackups(0)); $resize->isDestructive($server); // true $resize->hazards($server); // ['4 of the 4 retained weekly backups will be deleted'] $resize = $resize->withPreActionBackup(TakeBackup::intoFreeSlot(BackupSlot::Temporary));
Every option on a resize is an absolute value, not a delta: withMemory(8192) means "have
8192 MB". A resize built by adding to the current numbers doubles them.
For an action added to the API after this release:
$binarylane->serverActions()->perform(1234, 'some_new_action', ['field' => 'value']);
DNS
Zones are addressed by name. Names are normalised on the way in — lower-cased, trimmed, and stripped of a trailing dot.
$records = $binarylane->domains()->records('example.com'); $records->create(DomainRecord::a('www', '203.0.113.10')); $records->create(DomainRecord::mx('mail.example.com', priority: 10)); $records->create(DomainRecord::txt('_dmarc', 'v=DMARC1; p=quarantine')); $records->create(DomainRecord::caa('issue', 'letsencrypt.org'));
Three things differ from most DNS APIs:
- the apex is
@, not an empty string, and*is a wildcard. An empty name is converted rather than sent; - the TTL is fixed at 3600 and cannot be chosen — "the default and only supported value", says the specification. None is sent;
- the update is a
PUTthat retains what it is not given, with empty string clearing a value and null keeping it. That is backwards from the create, soupdate()takes an array and passes it through unfiltered:
$records->update(42, ['data' => '203.0.113.20']); // just the address $records->update(42, ['tag' => '']); // clear the tag $records->replace(42, DomainRecord::a('www', '203.0.113.20'));
upsert() is the operation a DNS updater wants and the API does not offer. It refuses rather
than guesses when several records share a name and type, because that is round-robin and not a
duplicate:
$records->upsert(DomainRecord::a('www', '203.0.113.10'));
domains()->records($zone) binds the zone for a sequence of calls. $binarylane->records() is
the same endpoint unbound, taking the zone as its first argument — worth reaching for when the
zone varies per call rather than per block:
$binarylane->records()->get('example.com', 42);
Adding a zone here does not delegate it. Domain::$currentNameservers is what the domain
actually resolves to:
$domain = $binarylane->domains()->get('example.com'); $domain->isDelegatedTo($binarylane->domains()->publicNameservers()); // false = serving nothing
Everything else
$binarylane->account()->get(); $binarylane->actions()->list(); $binarylane->billing()->unpaidFailedInvoices(); // what blocks new services $binarylane->dataUsages()->total(); // the allowance is POOLED $binarylane->domains()->list(); $binarylane->images()->distributions(); $binarylane->loadBalancers()->availability(); $binarylane->regions()->available(); $binarylane->reverseNames()->all(); $binarylane->sampleSets()->latest(1234); $binarylane->sizes()->forImage('ubuntu-24-04-lts'); // only the sizes it can install on $binarylane->software()->availableFor('ubuntu-24-04-lts'); $binarylane->sshKeys()->defaults(); // deployed to every new server $binarylane->vpcs()->serverIds(3);
Two of those repay a second look. sizes()->list() is the raw catalogue and the wrong thing to
build a create form from: it contains sizes your image cannot be installed on at all. Filtering
on a SQL Server edition took a 21-size catalogue down to 13. And data transfer allowance is
pooled across the account, so a single server over its own number may be fine; the total is the
comparison that means something.
Extending it
Every endpoint is an Endpoint subclass, and Client::endpoint() will construct anybody's:
use Hampel\BinaryLane\Api\Endpoint\Endpoint; final class Fleet extends Endpoint { /** @return \Generator<int, Server> */ public function idle(): \Generator { return $this->apiEach('servers', 'servers', Server::fromArray(...)); } } $binarylane->endpoint(Fleet::class)->idle();
There is nothing to register and no container — the class is the registration, and static analysis follows the return type through. Or reach the transport directly:
$binarylane->connection()->get('some/new/path')->collection('things');
Entities
Every entity is a readonly value object with a fromArray() and a raw property holding the
response as it arrived — so a field added to the API after this release is readable without
waiting for a release of this package:
$server->raw['something_new'];
They are JsonSerializable, and round-trip through json_encode() as the API sent them.
Where a value carries a credential — console URLs, user-data, image download links, invoice
URLs — the entity withholds it from var_dump() and print_r().
Testing against it
The package takes any PSR-18 client, so the seam it exposes to you is the seam its own suite
drives it through. A stub implementing sendRequest() is all a test needs; in Laravel,
Http::fake() works, and Http::preventStrayRequests() reaches your test naming the URL
rather than being dressed up as a transport failure.
Version support
PHP 8.3, 8.4 and 8.5. CI runs the floor with --prefer-lowest, the floor with current
dependencies, and the ceiling; PHPStan runs at level 10 across the whole PHP range.
This package wraps specification version 0.40.0. BinaryLane describes the specification as in
active development, and states that breaking changes are possible without the API version
changing. The X-Spec-Version response header reports which version answered.
Licence
MIT. See LICENSE.md.