hampel / binarylane-api-laravel
Laravel service provider, manager, facade and queued action polling for the BinaryLane API client, with Http::fake() support
Requires
- php: >=8.3
- guzzlehttp/guzzle: ^7.8|^8.0
- guzzlehttp/psr7: ^2.0|^3.0
- hampel/binarylane-api: ^0.3
- illuminate/contracts: ^12.0|^13.0
- illuminate/http: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
- 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)
- illuminate/bus: ^12.0|^13.0
- illuminate/queue: ^12.0|^13.0
- larastan/larastan: ^3.4.2
- laravel/pint: ^1.30
- orchestra/testbench: ^10.0|^11.0
- phpstan/phpstan: ^2.1.22
- phpunit/phpunit: ^11.0|^12.0
Suggests
- illuminate/bus: Required by AwaitAction (^12.0|^13.0)
- illuminate/queue: Required by AwaitAction (^12.0|^13.0)
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-13 23:19:55 UTC
README
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, soHttp::fake(),Http::assertSent()andHttp::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.
AwaitActionpolls 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/queueandilluminate/busare 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, loadingAwaitActionfails with a missing trait. - A real queue connection. The job polls by releasing itself with a delay, which the
syncdriver cannot do, so it raisesQueueRequiredthere — 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, andqueue: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 raisesMalformedResponseException— 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 passawait()await:callable so the test does not sleep. - Server actions are told apart by their body. They are all one
POSTtoservers/{id}/actions, so assert on$request['type'], which works because the core package writesapplication/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.