hampel / linode-api
A PHP client for the Linode (Akamai Cloud) API - domains, DNS records and account verification - over any PSR-18 HTTP client
Requires
- php: >=8.3
- ext-ctype: *
- 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
README
By Simon Hampel
A PHP client for the Linode (Akamai Cloud) API, built on PSR-18.
It wraps the DNS endpoints — zones and records — and the two endpoints that answer does this token work, and what may it do. That is a dozen of Linode's three hundred, deliberately: the rest are reachable through the same client without waiting for a release, and extending it is one class.
Installation
composer require hampel/linode-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 Hampel\Linode\Api\Client; use Hampel\Linode\Api\Entity\DomainRecord; $linode = Client::withToken('MY-TOKEN', new Guzzle()); $linode->verify(); // does this token work? $zone = $linode->domains()->findByName('example.com'); // null when the account has no such zone $linode->domains()->records($zone)->create( DomainRecord::a('www', '203.0.113.10')->withTtl(300) );
Client::withToken() finds a PSR-17 factory for you — Guzzle's, Nyholm's or Diactoros',
whichever is installed. The long form takes everything explicitly, including a PSR-3 logger:
use GuzzleHttp\Psr7\HttpFactory; use Hampel\Linode\Api\Authentication\AccessToken; use Hampel\Linode\Api\Config; $factory = new HttpFactory(); // PSR-17, fills both the request and stream roles $linode = new Client(new Config(), new AccessToken('MY-TOKEN'), new Guzzle(), $factory, $factory, $logger);
Requests are logged at debug, failures at error, and a nearly-spent rate limit at
warning. The token is never logged: AccessToken::describe() prints its length and last
four characters and nothing else.
Does this token work?
The question every integration should ask before doing anything, and the API makes it cheap
to answer. GET /v4/profile needs no OAuth scope at all, so it succeeds for any
credential that is valid and 401s for one that is not — while every other endpoint conflates
your token is wrong with your token may not do this.
use Hampel\Linode\Api\Exception\NotAuthenticatedException; try { $status = $linode->verify(); } catch (NotAuthenticatedException) { // missing, wrong, expired or revoked - Linode answers the same "Invalid Token" for all four } $status->username(); // 'exampleuser' $status->isRestricted(); // is this user limited object by object? $status->allows('domains:read_write'); // read_write also satisfies read_only $status->missing(['domains:read_write']); // [] when the token can manage DNS $status->summary(); // one line for a startup log; carries no token
One request. The scopes come back in the X-OAuth-Scopes response header, which Linode sends
on every response — so asking what a token may do costs nothing extra.
A bad token raises rather than returning a status object that says so. Returning
$status->valid === false invites a caller who forgot to check a boolean to carry on as
though everything were fine, and the next thing they do is delete a record.
Restricted users, and the 204 that means the opposite of what it looks like
A Linode user is either unrestricted — able to do whatever the token's scopes allow — or
restricted to particular objects, with the limits written down in GET /v4/profile/grants.
That endpoint answers 204 No Content for an unrestricted user. Read as an ordinary
response, an empty body is an empty grants object, which says the user may do nothing — the
exact opposite of the truth. This package returns null for it, and restricted is what to
branch on:
$profile = $linode->profile()->get(); $grants = $profile->restricted ? $linode->profile()->grants() : null; $grants?->canAddDomains(); // account-wide: may this user create new zones? $grants?->canWriteDomain($id); // per zone $grants?->accountAccess(); // 'read_only', 'read_write', or null
So there are three answers to "may this token create a zone", not two: unrestricted, or restricted-and-granted, or refused.
The account
GET /v4/account needs account:read_only, which a DNS token will not usually carry — and
that separation is correct rather than a limitation to work around. Use verify() to check a
token; use this only when the billing details are the thing you actually want.
$account = $linode->account()->get(); // raises NotPermittedException without the scope $account = $linode->account()->find(); // null instead, for a diagnostic that reports what it can
Domains
$linode->domains()->list(); // one page $linode->domains()->each(); // every zone, lazily $linode->domains()->all(); // every zone, as a list $linode->domains()->get($id); // raises if it is not there $linode->domains()->find($id); // null if it is not there $linode->domains()->findByName('example.com'); // null if it is not there $linode->domains()->zoneFile($id); // what Linode serves, line by line (see below)
Creating one:
use Hampel\Linode\Api\Entity\Domain; $linode->domains()->create( Domain::master('example.com', 'hostmaster@example.com') ->withTtl(300) ->withTags(['production']) ); $linode->domains()->create(Domain::slave('example.com', ['203.0.113.1']));
findByName() is the one to reach for on a real account: the name is what you have and the
id is what the API wants. A domain is unique across the whole of Linode, not just across
your account, so at most one thing can come back.
The methods that act on a zone take the zone itself, so a lookup feeds straight into what
follows — update(), delete(), zoneFile(), cloneTo() and records() all accept a
Domain or an id. get() and find() take an id only, on purpose: they produce a Domain,
so passing one in would be a round trip to fetch what you already hold.
$zone = $linode->domains()->findByName('example.com'); if ($zone !== null) { $linode->domains()->update($zone, ['ttl_sec' => 300]); }
That is worth more than symmetry. findByName() answers ?Domain, and Domain::$id is
?int in its own right because a zone built locally has no id — so narrowing away the first
null does not narrow away the second, and update($zone->id, …) fails static analysis even
after a correct null check. $zone->requireId() is the alternative and this is the shorter
one.
Updating, and the PUT that is really a PATCH
Every write on this API is a PUT, and every one of them applies only the fields it is given.
Sending {"ttl_sec": 300} changes the TTL and leaves the zone alone.
Optional fields on an entity are null until set, and only the ones that are not null are sent. So a domain built from scratch sends what you set on it, and one read back from the API sends everything it has:
$linode->domains()->update($id, ['ttl_sec' => 3600]); // one field $domain = $linode->domains()->get($id); $linode->domains()->update($domain, $domain->disabled()); // every field, with status changed
The smaller request is also the one that cannot overwrite a change somebody else made in between.
Disabling is not deleting. A disabled zone keeps every record and answers none of them,
which makes it the reversible way to take a zone out of service. delete() is not reversible
and takes the records with it.
Records
$records = $linode->domains()->records($zone); // a Domain or an id; bound, so no id per call $records->all(); $records->named('www'); $records->ofType(RecordType::MX); $records->create(DomainRecord::a('www', '203.0.113.10')); $records->update($recordId, ['ttl_sec' => 300]); $records->delete($recordId);
The same operations are on $linode->records() with the zone id as the first argument, for
when a call is on its own.
Which fields mean anything depends entirely on the type, and Linode rejects a field that does not belong to the type being created rather than ignoring it. So there is a named constructor per type, which takes what that type needs:
DomainRecord::a('www', '203.0.113.10'); DomainRecord::aaaa('www', '2001:db8::1'); DomainRecord::cname('shop', 'shops.example.net'); DomainRecord::ns('ns1.linode.com'); DomainRecord::mx('mail.example.com', priority: 10); DomainRecord::nullMx(); // RFC 7505: this domain takes no mail DomainRecord::txt('_dmarc', 'v=DMARC1; p=quarantine'); DomainRecord::srv('sip', 'tcp', 'sip.example.com', port: 5060, priority: 10, weight: 5); DomainRecord::caa(CaaTag::Issue, 'letsencrypt.org'); DomainRecord::ptr('10', 'www.example.com');
name is relative to the zone, not an FQDN: www in example.com is www.example.com,
and an empty name is the zone apex — which is what an MX or a CAA for the domain itself wants.
$record->fqdn('example.com') renders the whole thing.
Four traps live in these, and each is why the constructor looks the way it does:
- SRV takes its service and protocol undecorated. Linode prepends the underscore itself
and appends the period, so it is
srv('sip', 'tcp', ...)and never'_sip._tcp'— the decorated form becomes__sip.and matches nothing, with no error. This package refuses a leading underscore rather than passing it through. - SRV has no name of its own. Linode composes it from the service and the protocol, so
nameis not sent for one. - A record's type cannot be changed. The field is not in the update schema at all; turning an A into a CNAME means deleting and recreating.
- Nothing stops a duplicate. Linode will hold two identical A records for one name and
DNS will serve both.
named()first, where that matters.
TTLs are rounded up, silently
Linode accepts a fixed list of intervals — 0, 30, 120, 300, 3600, 7200, 14400, 28800, 57600, 86400, 172800, 345600, 604800, 1209600, 2419200 — and quietly rounds anything else up to the next one, with a 200 and no indication that the stored value is not the one you sent. Ask for 60 and you get 120; ask for 900 and you get 3600.
The same rule governs a zone's ttl_sec, refresh_sec, retry_sec and expire_sec and a
record's ttl_sec.
This contradicts Linode's own documentation, which describes a record's
ttl_secas rounded to the nearest valid value off a list starting at 300. Measured against the live API on 12 September 2026 by writing each value to a real record and reading back what was stored:
asked 0 1 30 60 120 300 900 3000 86401 2419201 stored 0 30 30 120 120 300 3600 3600 172800 2419200 30 and 120 are accepted for a record, and 900 stores 3600 rather than the 300 "nearest" would give. The zone rule was measured in the same run and does match its documentation.
Nothing in this package rewrites your number — a client that quietly changes a value is the
same failure one layer in. Support\Ttl reports what a value will become:
Ttl::round(60); // 120 Ttl::round(900); // 3600 Domain::master(...)->withTtl(900)->effectiveTtl(); // 3600 DomainRecord::a('www', '203.0.113.1')->withTtl(900)->effectiveTtl(); // 3600
Zero is not "no caching", and it means different things on a zone and on a record. Both were measured off the authoritative nameserver:
- On a zone it means "use the default", which differs per field: 86400 for a TTL, 14400 for refresh and retry, 1209600 for expire.
- On a record it inherits the zone's TTL — which Linode does not document. So
effectiveTtl()takes the zone to resolve it, and answersnullwithout one rather than guessing:
$record->effectiveTtl($zone); // 3600, inherited from the zone $record->effectiveTtl(); // null - a record does not know its zone
Zero is also what every one of these reports until it has been set.
Filtering and sorting
Filtering on this API is a request header, not a query string. There is no
?domain=example.com; there is an X-Filter header carrying a JSON object.
use Hampel\Linode\Api\Support\Filter; $linode->domains()->all(Filter::where('tags', 'production')->orderBy('domain')); $records->all(Filter::anyOf( Filter::where('type', 'A'), Filter::where('type', 'AAAA'), )); $linode->domains()->list(1, Filter::make()->contains('domain', 'example')->orderBy('domain', 'desc'));
A filter is immutable, so a base filter can be kept in a property and specialised per call.
Every operator Linode defines is a method: and, not, contains, greaterThan,
greaterOrEqual, lessThan, lessOrEqual, orderBy, and Filter::anyOf() for +or.
Only some fields are filterable, and the API decides which. Naming another is a 400 rather
than an ignored condition — the better failure, but still one. Domain::FILTERABLE and
DomainRecord::FILTERABLE are the lists.
Pagination
Every collection answers in the same envelope, and Page reads it:
$page = $linode->domains()->list(2); $page->items; // this page count($page); // how many are on it $page->total; // how many there are altogether ("results") $page->lastPage; // the last page number ("pages") $page->hasMore();
each() walks the pages lazily, fetching one only when the previous is consumed — so stopping
early stops making requests. A walk is a sample, not a snapshot: each page is its own
request against a collection that can change between them, so order explicitly where it
matters and de-duplicate by id where completeness does.
Linode refuses a page smaller than 25. ?page_size=1 is a 400 — a surprise to anyone who
has asked another API for one item to see the shape of it. This package refuses it before
spending a request. The default is 100, the maximum 500, and new Config(pageSize: 500) sets
it for every list a client makes.
Errors
Everything is reported in one envelope — {"errors": [{"field": "…", "reason": "…"}]} — and
the status is what separates the kinds. The exception hierarchy is that separation, so a
consumer catches what it can act on instead of matching on a message:
| exception | status | what it means |
|---|---|---|
ValidationException |
400 | a value was rejected. fieldErrors() is the shape a form wants |
NotAuthenticatedException |
401 | the credential is no good — missing, wrong, expired, revoked |
NotPermittedException |
401 or 403 | the token is real and lacks the scope, or the user lacks the grant |
NotFoundException |
404 | no such record — or no such path; Linode does not distinguish |
TooManyRequestsException |
429 | rate limited, and the only thing that means so |
ServerException |
5xx | Linode failed; a retry is reasonable |
MalformedResponseException |
2xx | success, with a body that is not JSON |
RequestException |
— | the request never got an answer: DNS, TLS, a timeout |
All but the last extend ApiException; all of them implement ExceptionInterface.
use Hampel\Linode\Api\Exception\ValidationException; try { $records->create(DomainRecord::mx('mail.example.com')); } catch (ValidationException $e) { $e->reasons(); // ['Priority must be 0-255'] $e->fieldErrors(); // ['priority' => ['Priority must be 0-255']] $e->concerns('priority'); // true }
A scope failure is a 401, not a 403
This is the one place the exception type deliberately does not follow the status, and it is
not a preference. Measured on 12 September 2026 with a token holding domains:read_write and
nothing else:
401 X-OAuth-Scopes: domains:read_write "Your OAuth token is not authorized
X-Accepted-OAuth-Scopes: account:read_only to use this endpoint."
401 X-OAuth-Scopes: unknown "Invalid Token"
Same status; opposite fixes. The first says widen the token's scopes, the second says replace the credential. Collapsing them into one type would leave you matching on the reason string, which is what this hierarchy exists to prevent.
X-OAuth-Scopes separates them on a fact rather than on prose: Linode can only report a
token's scopes for a token it recognises. So a 401 naming them becomes NotPermittedException
and a 401 saying unknown becomes NotAuthenticatedException. A stripped header degrades to
the second, which is the conservative reading.
catch (NotPermittedException $e) { $e->isScopeFailure(); // true: the token's scopes; false: a restricted user's grants $e->requiredScopes(); // account:read_only - what the endpoint wanted $e->heldScopes(); // domains:read_write - what the token has }
The message says so too, because the status contradicts it and the message is what most people read.
MalformedResponseException is the one worth understanding. A maintenance page, a proxy's
error document and a truncated body are all a 200 with something other than JSON in it.
Decoded permissively they become an empty array, which reaches the caller as this account has
no domains — and code that acts on that answer deletes things. So a 2xx that is not JSON
raises, and it extends ApiException so an existing catch still sees it.
Things about the Linode API worth not rediscovering
Each of these is asserted in tests/ or exercised in harness/, so if one stops being true
something should say so. The ones marked measured were read off the live API on 12 September
2026.
- A
Retry-Afteris on every response, including a 200 — measured: 60 besideX-RateLimit-Remaining: 1839on a fresh window, 46 on a later successful call, so it counts down to the reset rather than being fixed. Its presence says nothing about whether you were throttled; a client that backed off on seeing one would sleep after every call. OnlyTooManyRequestsExceptionmeans you were throttled. - An insufficient scope is a 401, not a 403 — measured; see above.
X-OAuth-Scopesis what tells it apart from a bad credential. - The rate limit is reported on every response as
X-RateLimit-Limit,X-RateLimit-RemainingandX-RateLimit-Reset(a unix timestamp, not a duration).$response->metacarries them, so an integration walking a large account can slow itself down rather than wait to be refused. 1840 on an unauthenticated request, measured; it varies by endpoint, so read it rather than assuming a figure. - The deployed API runs ahead of the published specification.
X-Spec-Versionreported 4.235.1 live while the specification on GitHub was at 4.215.0 — measured. Worth knowing before concluding that an endpoint does not exist. - A missing token and a made-up one are indistinguishable. Both answer
401 {"errors": [{"reason": "Invalid Token"}]}, byte for byte — measured. Nothing in the reply says which of the four causes it was, so no message here pretends to know. page_sizebelow 25 is a 400,{"field": "page_size", "reason": "Must be 25-500"}— measured.- An
X-Filterthat is not JSON is a 400 namingX-Filter— measured. A filter naming a field that is not filterable is a 400 too, rather than an ignored condition. - Dates carry no timezone and are UTC. Every
created,updatedandactive_sinceis2018-01-01T00:01:01— noZ, no offset.new DateTimeImmutable()on that reads it in PHP's own default timezone, so the same response is a different instant on a box set to Australia/Sydney, silently. This package supplies UTC rather than inferring it. - A successful DELETE is
{}with a 200 —Content-Length: 2, measured — not a 204 and not an empty body. So it decodes like any other response. - A 204 is the only success with a legitimately empty body, and that is
GET /v4/profile/grantson an unrestricted user. An empty-bodied 200 raisesMalformedResponseException, because on this API nothing legitimately answers one — see the note under Testing code that uses this. GET /v4/profile/grantsis a 204 for an unrestricted user — see above; it is the trap in this API most likely to be read backwards.GET /v4/profileneeds no scope, which is what makes it the token check.- A zone's SOA and NS records are not records. Linode generates and serves them without
representing them in the record endpoint, so a zone that resolves perfectly well can answer
with an empty list.
zoneFile()is what shows the whole picture. - The zone file lags the record endpoints, by minutes, and is not a read-your-writes view — measured. Straight after a change it rendered a TTL two edits old and a record that had already been deleted; on another run a change had still not appeared after 160 seconds. It is authoritative about what is being served, which is exactly why it lags; it is the wrong thing to diff straight after an edit to confirm the edit landed. Read the record endpoint for that.
- A slave zone's records cannot be written — they arrive by transfer.
v4betais a URL segment, so pointing at it moves every request a client makes, not just the beta ones. That is why it is$linode->withVersion()returning a second client rather than a flag on a call.- Linode's DNS has no SSHFP, TLSA, NAPTR, DNSKEY or DS.
RecordTypeis the complete list, and a zone that needs one of those cannot be hosted here whatever this package does.
Extending it
This package wraps a dozen endpoints. For the rest, two ways past it, neither needing a release:
$linode->connection()->get('linode/instances')->array('data'); // once $linode->endpoint(Instances::class)->all(); // more than once
The second is the one to build on. An Endpoint subclass gets the pagination helpers, which
work for an endpoint this package has never heard of, because every collection on this API
answers in the same envelope:
use Hampel\Linode\Api\Endpoint\Endpoint; final class Instances extends Endpoint { /** @return \Generator<int, array<string, mixed>> */ public function all(): \Generator { return $this->apiEach('linode/instances', static fn (array $row): array => $row); } }
There is nothing to register, no container and no string keys — the class is the
registration, and static analysis follows the return type through.
tests/Fixture/Instances.php is a worked example with a test behind it.
Testing code that uses this
Fake the PSR-18 client, not this package. The seam the package offers its consumers is the
same one its own test suite drives it through — sendRequest(), one method — so a stub client
that answers from a queue needs no mocking framework and no network:
final class StubClient implements \Psr\Http\Client\ClientInterface { public function __construct(private array $queue) {} public function sendRequest(\Psr\Http\Message\RequestInterface $request): \Psr\Http\Message\ResponseInterface { return array_shift($this->queue); } } $linode = Client::withToken('test-token', new StubClient([ new Response(200, [], json_encode(['data' => [...], 'page' => 1, 'pages' => 1, 'results' => 1])), ]));
Mocking Client or an endpoint class instead tests your own mock. A framework's HTTP facade
fake will not see this traffic either, unless the framework's own client is what you injected —
which is what the Laravel wrapper below is for.
Give every fake a body. A 200 with an empty body raises MalformedResponseException
rather than reading as an empty collection, and that is deliberate: Laravel's Http::fake()
with no arguments answers every request with exactly that, so a fake whose body was forgotten
would otherwise read as "this account has no zones" and let the assertions pass. The only
success with a legitimately empty body is a 204.
Laravel
Nothing here depends on Laravel, and the transport is injected rather than chosen — so a
framework's own HTTP client can carry this traffic by implementing PSR-18's one method over
it. That is what makes Http::fake() and Http::preventStrayRequests() able to see these
requests, which they cannot when a package holds its own client.
A hampel/linode-api-laravel providing a service provider, a manager for named accounts, a
facade and that adapter is planned. It is not released yet; until it is, wire the client
up in a service provider of your own.
Versioning and support
^1.0 is the constraint to write. PHP 8.3 or later.
The public API is stable. A break in any class, method or signature outside tests/ and
harness/ means 2.0.0. Two consequences of that are worth knowing before you rely on them:
RecordType,DomainType,DomainStatusandCaaTagare enums, so an exhaustivematchover one throwsUnhandledMatchErrorthe day a case is added. Adding a case is therefore a major here — but write adefaultarm anyway.ApiException::$statusCode,$errors,$body,$retryAfterand$metaare the supported surface. What is inside one of Linode's error objects, and the shape of an entity'sraw, are the API's rather than this package's and are not covered.
Entities serialise to the payload the API sent, unchanged, so a field added to the API after
a release is reachable through $entity->raw without waiting for one.
One question about Linode's own behaviour is still open, and it cannot move a signature: a
restricted user's grants are covered by the test suite and have not been exercised against a
real restricted account. The trap in that endpoint — the 204 it answers for an unrestricted
user — is measured.
License
MIT — see LICENSE.md.