Search by

hampel / binarylane-api-laravel

hampel

Laravel service provider, manager, facade and queued action polling for the BinaryLane API client, with Http::fake() support

Package info

github.com/hampel/binarylane-api-laravel

pkg:composer/hampel/binarylane-api-laravel

Statistics

Installs: 22

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

0.4.0 2026-09-13 23:07 UTC

This package is auto-updated.

Last update: 2026-09-13 23:19:55 UTC


README

Tests Latest Version on Packagist Total Downloads Open Issues License

By Simon Hampel

Laravel integration for hampel/binarylane-api — a service provider, a manager for named accounts, a facade, and a queued job that waits for BinaryLane's actions to finish.

Three things it adds that an application would otherwise write for itself:

  • Http::fake() sees the API client's traffic. The core package carries its own PSR-18 client, so by default Laravel's HTTP fakes know nothing about it. Here every request goes through Laravel's own handler stack, so Http::fake(), Http::assertSent() and Http::preventStrayRequests() all work.
  • Named accounts. A token per account, a default, and BinaryLane::client('name') to reach one — the shape Laravel's own database and mail managers take.
  • Waiting for an action without blocking a request. Nearly every change on this API answers with an action to poll rather than a result. AwaitAction polls it on the queue and fires an event saying how it ended.

The request building, status mapping and exception hierarchy are the core package's, untouched: a 401 and a 404 stay different exceptions rather than both becoming an unsuccessful response.

Requirements

PHP 8.3 or later, and Laravel 12 or 13.

Laravel Zero works too, with the HTTP component installed (php <app> app:install http). Laravel binds Illuminate\Http\Client\Factory as a singleton in FoundationServiceProvider, which a Laravel Zero application does not register; unbound, Http::fake() silently fails to intercept and the request reaches the real API. This package binds one when nothing else has, so the behaviour is the same on both.

AwaitAction needs illuminate/queue and illuminate/bus, which are not installed with this package. A Laravel application has both already. In Laravel Zero, php <app> app:install queue adds them; without them the job class cannot be loaded, and everything else works.

Installation

composer require hampel/binarylane-api-laravel

In a Laravel application the provider and the BinaryLane alias are discovered automatically. Publish the config file if you want to edit it:

php artisan vendor:publish --tag=binarylane-config

Laravel Zero does not run package discovery, so there the provider has to be listed by hand, in config/app.php:

'providers' => [
    Hampel\BinaryLane\Api\Laravel\BinaryLaneServiceProvider::class,
],

The global BinaryLane alias is not registered either. Import the facade class — use Hampel\BinaryLane\Api\Laravel\Facades\BinaryLane; — or inject BinaryLaneManager.

vendor:publish --tag=binarylane-config works in Laravel Zero once the provider is listed, though list does not show it: Laravel Zero hides the command rather than removing it. Before the provider is listed it answers that there is nothing to publish.

Configuration

An account needs a token and nothing else — there is one BinaryLane, so there is no URL to configure.

BINARYLANE_API_TOKEN=your-api-token

A BinaryLane token can do everything its account can do. There is one kind of token: no scopes, no expiry, no read-only variant. A token configured for a dashboard that only lists servers can also cancel them. Keep it out of anything that does not need it.

The shipped config/binarylane.php defines one account called main. Add more by naming them:

'default' => 'production',

'accounts' => [
    'production' => [
        'token' => env('BINARYLANE_API_TOKEN'),
    ],

    'reseller' => [
        'token' => env('RESELLER_BINARYLANE_API_TOKEN'),
    ],
],

An account with no token is refused when its client is built, rather than allowed to reach the API and come back 401 — which would read as a revoked token rather than as an unset environment variable. An empty string counts as no token. It raises Hampel\BinaryLane\Api\Laravel\Exception\InvalidConfiguration, which extends the core package's BinaryLaneException.

An application config file named binarylane.php replaces these settings key by key. Laravel merges a package's configuration shallowly, and the application's file wins for every top-level key it defines. So an application that already keeps its own settings in config/binarylane.php — a timeout meaning something else, say — silently changes this package's settings of the same name, with no error from either. Publish this file and edit it, or name your own settings file something else.

Page size and base URI describe the API, not an account

'per_page' => env('BINARYLANE_PER_PAGE'),   // 1 to 200; null uses the API's default of 20
'base_uri' => env('BINARYLANE_API_URL'),    // null uses BinaryLane's own host

Both are shared by every account's client and validated when the first client is built. A per_page of 0 is refused, although the API accepts it: it asks for a total and no items, which as a default would make every list come back empty. base_uri exists for a recorded fixture served locally and for an outbound proxy that terminates the connection.

Transport

'timeout' => 10,
'connect_timeout' => 5,

Applied to every request, alongside any Http::globalRequestMiddleware() the application has configured and the transport settings from Http::globalOptions() — a proxy, a CA bundle or client certificate, the protocol version, curl options. Global options that would change the request itself are not applied: headers, auth, query and the body options would overwrite what the core package built, including its Authorization header. These timeouts bound one request, not the work it starts — how long to wait for an action is AwaitAction's timeout.

Usage

The facade reaches the default account directly:

use Hampel\BinaryLane\Api\Laravel\Facades\BinaryLane;

$account = BinaryLane::verify();               // does this token work, and is the account usable?

$servers = BinaryLane::servers()->list();      // the first page - 20 unless configured otherwise
$server = BinaryLane::servers()->get(1234);

Name an account to reach another:

$servers = BinaryLane::client('reseller')->servers()->list();

It is client() and not account(). BinaryLane::account() is the core package's account endpoint — GET /v2/account — and the manager forwards unknown calls to the default client, so a method on both would mean different things depending on which class you thought you were calling.

Everything past that point is the core package — see its documentation for the endpoints, entities, pagination, server creation and the complete-or-error pattern of question-shaped actions.

Inject the manager where a facade is not wanted:

use Hampel\BinaryLane\Api\Laravel\BinaryLaneManager;

public function __construct(private readonly BinaryLaneManager $binarylane) {}

$this->binarylane->client('reseller')->servers()->list();

Waiting for an action happens on the queue, not in the request

A server action returns a receipt, not a result. Powering a server on, resizing it, rebuilding it and creating it all answer with an action that has not finished yet. The core package's actions()->await() polls until it does, which blocks — right in a console command, wrong in a web request, where a server build would hold the request open for minutes.

AwaitAction is that wait on the queue:

use Hampel\BinaryLane\Api\Laravel\Jobs\AwaitAction;

$action = BinaryLane::serverActions()->powerOn(1234);

if ($action !== null) {
    dispatch(new AwaitAction($action));
}

Check for null. Every server action may answer with a bodiless 202 and no action to wait on. The job takes an action or an id and nothing else, and an id below 1 is refused when it is constructed — in the request that dispatched it, rather than later in a worker's log.

A server build names its actions rather than returning one:

$created = BinaryLane::servers()->create($request);

foreach ($created->actionIds() as $id) {
    dispatch(new AwaitAction($id, timeout: 7200));
}

The constructor takes the action or its id, then optionally the account name, a timeout and a polling interval:

new AwaitAction($action, account: 'reseller', timeout: 3600, interval: 10);
argument default meaning
account the default account which configured account the action belongs to
timeout 3600 seconds from dispatch to give up, time in the queue included
interval 10 seconds between polls; at most 900 on Amazon SQS

How it ends is an event

when the action the job fires and
completed ActionCompleted succeeds
errored ActionFailed succeeds — an errored action stays errored, so there is nothing to retry
is waiting on a question or an unpaid invoice ActionBlocked succeeds — neither resolves by waiting
was still running at the deadline ActionTimedOut succeeds — nothing was cancelled

All four live in Hampel\BinaryLane\Api\Laravel\Events, and each carries the account name and the action as last seen; ActionTimedOut also carries waited, in seconds. Listen for the ones you care about:

use Hampel\BinaryLane\Api\Laravel\Events\ActionBlocked;
use Hampel\BinaryLane\Api\Laravel\Events\ActionCompleted;
use Hampel\BinaryLane\Api\Laravel\Events\ActionFailed;
use Illuminate\Support\Facades\Event;

Event::listen(function (ActionCompleted $event) {
    // $event->action->resourceId is the server; $event->action->type is what was done to it
});

Event::listen(function (ActionFailed $event) {
    // $event->action->failureReason() when BinaryLane gave one - often it did not
});

Event::listen(function (ActionBlocked $event) {
    // answer with actions()->proceed(), or pay invoice $event->action->blockingInvoiceId,
    // then dispatch a new AwaitAction if the outcome still matters
});

The events share no parent class, deliberately. Laravel matches a listener by class and by interface, never by parent class, so a listener on a common base would hear nothing.

When it cannot find out, it retries or fails

A poll that failed in a way the next one may not repeat is retried until the deadline — a 5xx, a 429, a transport failure, a malformed response, or an answer about some other action. A 429's Retry-After is honoured when it is longer than the interval. Past the deadline, the job fails.

Anything else fails the job at once, because asking again will not change it: a token that is not valid, an action id that does not exist on the account, an account name no longer in the configuration. A failed job is reported and lands in failed_jobs like any other.

A failed, blocked or timed-out action is also logged at warning, with the account, the action id, its type and the resource it acts on. The events are the structured report; the log line is there so an application that listens for none of them still hears about the outcomes that need a person. A completed action is not logged.

Four things it needs from the queue

  • The queue component. illuminate/queue and illuminate/bus are suggested rather than required, so an application that never dispatches the job does not install the queue and the database layer it depends on. Without them, loading AwaitAction fails with a missing trait.
  • A real queue connection. The job polls by releasing itself with a delay, which the sync driver cannot do, so it raises QueueRequired there — on every run, including one where the action happened to be finished already, so it cannot appear to work in development and then fail silently in production.
  • Nothing about --tries. The job sets $tries = 0, which is unlimited: every release counts as an attempt, and queue:work's own default of one try would otherwise fail the second poll. The deadline is the limit instead.
  • No credential in the payload. The job carries the account name and resolves it when it runs. It never serialises a client or a token.

It is tagged binarylane and binarylane:action:<id> for Horizon.

Errors

The core package's exceptions arrive untouched:

use Hampel\BinaryLane\Api\Exception\NotAuthenticatedException;
use Hampel\BinaryLane\Api\Exception\NotFoundException;
use Hampel\BinaryLane\Api\Exception\ValidationException;

try {
    $server = BinaryLane::servers()->get($id);
} catch (NotFoundException $e) {
    // no such server on this account
} catch (NotAuthenticatedException $e) {
    // the token is no good. A configuration error, not an empty result.
}

UnknownAccount, InvalidConfiguration and QueueRequired extend the core package's BinaryLaneException, so an application already catching that catches these too.

Testing

Fake the API with the vocabulary the rest of your suite already uses:

use Illuminate\Support\Facades\Http;

Http::preventStrayRequests();

Http::fake([
    'api.binarylane.com.au/*' => Http::response([
        'server' => ['id' => 1234, 'name' => 'vps01.example.com'],
    ]),
]);

$server = BinaryLane::servers()->get(1234);

Http::assertSent(fn ($request) => $request->hasHeader('Authorization'));

The package's real code path runs; only the socket is replaced. So a faked 404 still arrives as NotFoundException, and a faked 200 whose body is HTML still arrives as MalformedResponseException.

  • Give every fake a body. Http::fake() with no arguments answers every request with an empty 200, which raises MalformedResponseException — only a 202 and a 204 are successes with no body on this API. A body without the expected envelope key raises it too.
  • Anything that awaits needs one response per poll. Queue them with Http::fakeSequence(), and pass await() a wait: callable so the test does not sleep.
  • Server actions are told apart by their body. They are all one POST to servers/{id}/actions, so assert on $request['type'], which works because the core package writes application/json.
  • Order does not matter. Faking after the client has been resolved works, and so does Http::swap(new Factory) to start a test from a clean set of fakes: the transport looks the factory up at the moment of sending.

To test your own handling of AwaitAction, call handle() on a job with fake queue interactions, which records releases and failures rather than ignoring them:

$job = (new AwaitAction(5001))->withFakeQueueInteractions();

$job->handle(app(BinaryLaneManager::class), app('events'), app('log'));

$job->assertReleased(10);

Replace the transport entirely by binding binarylane.http_client — how an application with its own outbound HTTP policy makes this package use it:

use Hampel\BinaryLane\Api\Laravel\BinaryLaneServiceProvider;

$this->app->singleton(BinaryLaneServiceProvider::HTTP_CLIENT, fn () => $myPsr18Client);

The package binds that key only if nothing has already, so the override works from any service provider, whether it registers before or after this package's — including AppServiceProvider in a Laravel Zero config/app.php, where it is listed first.

This package does not use a Psr\Http\Client\ClientInterface binding, yours or another package's, and does not bind that key itself. It is one key shared by every package that uses it, so an application with several API integrations installed would otherwise get whichever registered last. To send several packages through one client, bind each package's own key to it.

What Laravel's HTTP events see

RequestSending fires; ResponseReceived and ConnectionFailed do not. Laravel raises the first from inside the handler stack this package sends through, and the other two from a layer above it. So Telescope's HTTP client watcher, which listens for ResponseReceived, will not show this traffic. The core package logs every request at debug through PSR-3, which reaches the application log, and logs nothing above debug: a failure arrives as an exception, and logging it is the catcher's decision. The token is never logged.

Versioning

hampel/binarylane-api is 0.x, so its public API can change in a minor release; this package constrains it at ^0.3 and expects to bump.

License

MIT. See LICENSE.md.