Search by

hampel / cloudflare-api

hampel

A PHP client for the Cloudflare API - zones, DNS records, Registrar registrations and token verification - over any PSR-18 HTTP client

Package info

github.com/hampel/cloudflare-api

pkg:composer/hampel/cloudflare-api

Statistics

Installs: 99

Dependents: 1

Suggesters: 0

Stars: 0

Open Issues: 0

1.2.0 2026-09-18 05:12 UTC

This package is auto-updated.

Last update: 2026-09-18 06:25:45 UTC


README

Tests Latest Version on Packagist Total Downloads Open Issues License

By Simon Hampel

A PHP client for the Cloudflare API, built on PSR-18. It covers DNS management — zones and records — Cloudflare Registrar registrations, and API token verification.

Installation

composer require hampel/cloudflare-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\Cloudflare\Api\Client;
use Hampel\Cloudflare\Api\Entity\DnsRecord;

$cloudflare = Client::withToken('MY-API-TOKEN', new Guzzle());

$cloudflare->verify();                       // does this token work?

$zone = $cloudflare->zones()->getByName('example.com');

foreach ($cloudflare->zones()->records($zone->id)->each() as $record) {
    echo $record->describe(), "\n";
}

$cloudflare->zones()->records($zone->id)
    ->create(DnsRecord::a($zone->fqdn('www'), '203.0.113.10')->proxy());

withToken() finds a PSR-17 factory for you — Guzzle's, Nyholm's or Diactoros', whichever is installed. The long form names everything:

use GuzzleHttp\Psr7\HttpFactory;
use Hampel\Cloudflare\Api\Authentication\ApiToken;
use Hampel\Cloudflare\Api\Config;

$factory = new HttpFactory();   // PSR-17, fills both the request and stream roles

$cloudflare = new Client(
    new Config(pageSize: 50),   // 5-50 if you set it at all - see Pagination
    new ApiToken('MY-API-TOKEN'),
    new Guzzle(),
    $factory,
    $factory,
    $logger,                    // PSR-3, optional
);

Requests are logged at debug and failures at error; a write to DNS and a nearly-spent rate limit are warning. The token is never logged: ApiToken keeps it out of __toString(), var_dump() and stack traces.

The PSR-18 client is always passed and never discovered — which HTTP client issues the request is a decision a host application may need to keep.

Tokens, not the Global API Key

Only API tokens are supported. Create one at dash.cloudflare.com/profile/api-tokens as a Custom Token:

To Permission
read zones and records Zone / Zone / Read and Zone / DNS / Read
change records Zone / DNS / Edit
read accounts (optional) Account / Account Settings / Read
read Registrar registrations (optional) Account / Registrar: Domains / Read

Restrict Zone Resources to the zones the token needs. The Registrar permission is account-level, so a token that carries it also needs Account Resources set to the account whose domains it reads.

The legacy Global API Key is not supported. It cannot be scoped to a zone, cannot be verified, and cannot be revoked without breaking everything else that holds it. Authentication is an interface, so an implementation can be supplied if one is ever needed.

Verifying a token

$token = $cloudflare->verify();

$token->isActive();                 // real, and usable right now
$token->expiresWithinDays(30);
$token->summary();                  // one line, carrying no part of the secret

An invalid token raises NotAuthenticatedException rather than returning an object saying so — whichever way Cloudflare refuses it. See below.

Verification ignores the token's IP address filter. A token restricted to other addresses verifies as active from this one, and then every zone and account call is refused with "Cannot use the access token from location". Verification says the credential exists and is live; it does not say it can be used from here. The first real call is what answers that, and it raises NotAuthenticatedException too — so a startup check that wants the whole answer lists one page of zones after verifying:

$cloudflare->verify();
$cloudflare->zones()->list(1, 5);   // raises NotAuthenticatedException if refused from here

Cloudflare reports a token's permissions nowhere — not on this endpoint, not in a response header. Verification says the credential is real and live, and nothing about what it may do. The only way to find out is to try, which is why Accounts::first() returns null instead of raising when the token cannot read accounts.

Zones

Read-only. Listing and fetching are here; creating, deleting and reconfiguring a zone are not.

$cloudflare->zones()->list();                     // one page
$cloudflare->zones()->each();                     // a generator over every page
$cloudflare->zones()->all();
$cloudflare->zones()->get($zoneId);
$cloudflare->zones()->find($zoneId);              // null when absent
$cloudflare->zones()->findByName('example.com');  // null when absent
$cloudflare->zones()->getByName('example.com');   // raises when absent

findByName() verifies the returned zone's name matches what was asked for. A filter the server ignored would otherwise come back as a 200 carrying the wrong zone.

A zone id is a 32-character hex string, not the domain name.

$zone->isActive();      // Cloudflare is answering for this domain
$zone->isPending();     // nameservers not yet pointed at Cloudflare
$zone->isPaused();      // zone-wide: every record served DNS-only
$zone->nameServers;
$zone->planLegacyId;    // 'free', 'pro' - stable, where planName is display text
$zone->fqdn('www');     // www.example.com
$zone->fqdn('');        // example.com - the apex

A pending zone accepts every DNS change and serves none of them. Nothing errors. Check isActive() before trusting a change to have taken effect.

DNS records

$records = $cloudflare->zones()->records($zoneId);

$records->list();
$records->each();
$records->all();
$records->ofType(RecordType::MX);
$records->named('www.example.com');
$records->get($recordId);
$records->find($recordId);              // null when absent
$records->create($record);
$records->patch($recordId, ['ttl' => 300]);
$records->replace($recordId, $record);
$records->delete($recordId);
$records->export();                     // the zone as a BIND file

The same operations are on $cloudflare->records() with the zone id as the first argument.

Record names are absolute

www.example.com, not www. The apex is the zone name itself. Zone::fqdn() builds one from a label.

patch() and replace() are not interchangeable

patch() is an HTTP PATCH: the fields you give it change and the rest are left alone.

replace() is an HTTP PUT, and every field not in the payload is reset to its default — the comment cleared, the tags dropped, the proxy turned off, the TTL returned to automatic. The call answers 200 and reports none of it.

patch() is what almost every caller wants.

Building records

DnsRecord::a('www.example.com', '203.0.113.10');
DnsRecord::aaaa('www.example.com', '2001:db8::1');
DnsRecord::cname('blog.example.com', 'example.ghost.io');
DnsRecord::mx('example.com', 'mail.example.com', priority: 10);
DnsRecord::txt('_dmarc.example.com', 'v=DMARC1; p=quarantine');
DnsRecord::ns('sub.example.com', 'ns1.elsewhere.com');
DnsRecord::ptr('10.113.0.203.in-addr.arpa', 'www.example.com');
DnsRecord::caa('example.com', CaaTag::Issue, 'letsencrypt.org');
DnsRecord::srv('_sip._tcp.example.com', 'sip.example.com', port: 5060, priority: 10, weight: 5);

DnsRecord::of(RecordType::OPENPGPKEY, $name, $content);   // any other content type
DnsRecord::components(RecordType::TLSA, $name, [...]);    // any other data type

Then refine:

$record->withTtl(3600)->withComment('why this exists')->withTags(['production']);  // tags need a paid plan
$record->proxy();     // A, AAAA and CNAME only
$record->unproxy();   // sends proxied: false rather than leaving it to the default

Pass the ordering arguments. Cloudflare requires a priority for MX, and SRV orders by priority and then weight. Leaving them out of mx() or srv() is deprecated since 1.2: it still gives the old default — 10 for MX, 0 for SRV — with an E_USER_DEPRECATED notice, and 2.0 will require them.

Comparing TXT values

One TXT value has several spellings: v=spf1 -all, "v=spf1 -all" and "v=spf1 " "-all" are the same record. The API returns content in whichever spelling it was written in — by this package, the dashboard or another tool — so string equality reports differences that are not there. Compare through the helpers instead:

$record->comparableContent() === DnsRecord::comparableTxt($expected);

Both join the quoted strings and remove the quoting. Content of any other type is returned unchanged.

Two families of record type

Eight types carry their value in content as a string: A, AAAA, CNAME, MX, NS, OPENPGPKEY, PTR, TXT.

The other thirteen carry it in a data object of components, and their content is read-only — Cloudflare generates it and refuses any attempt to set it: CAA, CERT, DNSKEY, DS, HTTPS, LOC, NAPTR, SMIMEA, SRV, SSHFP, SVCB, TLSA, URI.

RecordType::usesData() is the test. The named constructors already know the answer, and toArray() emits only the field that type is allowed to send — so a record read from the API can be sent straight back without its generated content being rejected.

An SRV record's service and protocol go in its name, decorated: _sip._tcp.example.com.

TTL

1 means automatic, which Cloudflare serves as 300 seconds — not one second. Anything else must be 60 to 86400 (30 on Enterprise zones), and is checked before the request is sent.

A proxied record has no TTL of its own; Cloudflare forces automatic.

Tags are a paid feature. On a Free zone the quota is zero and a record carrying one is refused with code 9300, whose message — "exceeding the quota of 0" — reads like a complaint about the request rather than about the plan.

Ttl::effective($record->ttl);   // 300 for automatic
Ttl::describe(1);               // "automatic (300s)"
$record->effectiveTtl();

Filtering

Cloudflare filters with query parameters, and every text field takes four predicates:

$query = RecordQuery::make()
    ->type(RecordType::A)
    ->nameEndsWith('.example.com')
    ->contentContains('203.0.113')
    ->proxied(false)
    ->tagged('production')
    ->orderBy('name', 'desc');

$records->all($query);

nameIs(), nameContains(), nameStartsWith(), nameEndsWith() — and the same four for content, comment and tag. matchAny() switches the query from AND to OR; tagMatchAny() does the same for tag conditions, which combine separately.

An unrecognised filter is not an error on this API — it is ignored, and the whole collection comes back with a 200. Measured: ?no_such_filter=x against a zone returned every record in it. RecordQuery refuses an empty condition value and an unorderable field for that reason.

Registrar

Read-only. The domains an account holds through Cloudflare Registrar:

$registrations = $cloudflare->registrations();

$registrations->each($zone->accountId);                 // a generator, walked by cursor
$registrations->all($zone->accountId);
$registrations->get($zone->accountId, 'example.com');   // raises NotFoundException if not held
$registrations->find($zone->accountId, 'example.com');  // null if not held
$registration->domainName;
$registration->status;                  // 'active'
$registration->expiresAt;               // DateTimeImmutable, UTC
$registration->autoRenew;
$registration->locked;
$registration->privacyMode;             // 'redaction'
$registration->isActive();
$registration->expiresWithinDays(90);
$registration->lapsesWithoutAction();   // auto-renew explicitly off

Registering, renewing and changing a domain are not wrapped. POST registrations registers a domain, which costs money, and PATCH changes its renewal and lock settings.

get() and find() lower-case the name first. Cloudflare's lookup is case-sensitive: a registered domain asked for in capitals answers 404, exactly as a domain the account does not hold.

status and privacyMode are strings rather than enums, because Cloudflare does not publish the set of values they take.

Pagination

$page = $records->list(page: 2, pageSize: 100);

$page->items;
$page->count();        // on this page
$page->total();        // across every page
$page->hasMore();
$page->currentPage();
$page->lastPage();

each() walks every page lazily — stopping early stops making requests.

Page size limits differ per endpoint. DNS records accept 1 to 5,000,000 and default to 100. Zones and accounts accept 5 to 50 and default to 20. Registrations accept 1 to 50. A size outside the range is refused before the request is sent.

Registrations page by cursor, not by number, so they have no list() returning a Page: each() follows the cursor to the end, and page would be ignored if sent.

A walk is a sample, not a snapshot: each page is its own request. Order explicitly, and de-duplicate by id where completeness matters.

Errors

Every exception implements Hampel\Cloudflare\Api\Exception\ExceptionInterface.

Exception Meaning
ValidationException 400 — a value was rejected
NotAuthenticatedException the credential is missing, wrong, unparseable, revoked, or refused from this address
NotPermittedException 403 — the token lacks a permission or the resource is outside it
NotFoundException 404 — no such DNS record, or no such path
ConflictException 409 — a record that cannot coexist with what is there
TooManyRequestsException 429
ServerException 5xx
ClientException any other failure
MalformedResponseException a 2xx whose body is not the JSON envelope
RequestException the request never got an answer
InvalidArgumentException refused before a request was made

All but the last two extend ApiException:

catch (ValidationException $e) {
    $e->statusCode;
    $e->codes();             // Cloudflare's numeric codes
    $e->messages();
    $e->hasCode(81057);
    $e->fieldErrors();       // ['ttl' => ['Invalid TTL'], 'data.tag' => [...]]
    $e->concerns('ttl');
}

Branch on hasCode() rather than on a message. The codes are documented and stable; the messages are prose.

A bad credential arrives three ways

Cloudflare refuses a token it cannot parse before authentication runs, so that failure wears a 400 rather than the 401 a well-formed but wrong token gets. And it refuses a token used from outside its IP address filter with a 403. Measured against the live API:

the token HTTP code
right shape, wrong value 401 1000
unparseable — a placeholder, a truncated value, a stray Bearer prefix 400 6003
used from an address outside its IP filter 403 9109

All three raise NotAuthenticatedException. The second and third are the ones worth knowing about, because their status points away from the credential: a 400 invites you to inspect a payload that is fine, and a 403 reads as a missing permission when no permission would help.

The location refusal is recognised by its message, "Cannot use the access token from location", because Cloudflare uses the same code 9109 for an unknown zone id.

Absence is reported three different ways

Measured against the live API:

a DNS record that does not exist 404, code 81044
a zone that does not exist, or is not yours 403, code 9109
an id that is not even the right shape 400, code 7000

So a missing zone arrives as NotPermittedException, not NotFoundException — Cloudflare will not confirm which zone ids exist to a credential that cannot see them.

Zones::find() absorbs the 9109 case and returns nullonly when the message is "Invalid zone identifier", because 9109 also arrives for a token refused by its IP filter, and that is raised as NotAuthenticatedException instead. It does not absorb a 403 carrying code 10000 either — "Authentication error", a token whose permissions or resources do not cover the zone — because reporting a credential problem as "the zone does not exist" sends whoever chases it to the wrong place.

If Cloudflare ever rewords "Invalid zone identifier", find() raises rather than returning null.

success: false on a 200

This API carries its own success flag, and a 2xx whose body says "success": false is a real shape. It is raised as a failure rather than returned as an empty result.

A 2xx whose body is not JSON is raised too, for the same reason: read permissively, a maintenance page or proxy error document becomes an empty array, which reaches the caller as "this zone has no records".

Rate limits

1200 requests per five minutes. Exceeding it blocks every call for the next five minutes, not only the one that went over.

The headers are sent per endpoint, not on every response. GET /zones carries Ratelimit: "list_zones";r=1200;t=1 and Ratelimit-Policy: "list_zones";q=1201;w=300; GET /user/tokens/verify carries neither. The policy is named after the operation, so a limit read from one endpoint says nothing about another.

$meta = $response->meta;

$meta->rateLimit;
$meta->rateLimitRemaining;
$meta->rateLimitResetsIn;
$meta->isNearingRateLimit();   // false when the headers were absent
$meta->ray;                    // CF-Ray, for a support ticket

A missing header reads as unknown, never as exhausted.

Bringing your own HTTP client

Anything implementing PSR-18 works, which is the point of the package: an application with its own proxy-aware, SSRF-guarded HTTP stack shares this code rather than needing a second client. It is also what lets a Laravel integration route this traffic through Http::fake().

Endpoints not yet wrapped

Most of them. This package covers DNS, Registrar registrations and token verification; Cloudflare's API has some two thousand paths. The rest are reachable without waiting for a release:

$cloudflare->connection()->get('zones/' . $zoneId . '/settings/ssl')->object();

For anything called more than once, ship an Endpoint subclass:

final class CustomHostnames extends Endpoint
{
    public function each(string $zoneId): \Generator
    {
        return $this->apiEach('zones/' . $zoneId . '/custom_hostnames', static fn (array $row) => $row);
    }

    protected function minimumPageSize(): int { return 5; }
    protected function maximumPageSize(): int { return 1000; }
    protected function collectionName(): string { return 'custom hostnames'; }
}

$cloudflare->endpoint(CustomHostnames::class)->each($zoneId);

There is nothing to register. Pagination, error handling and the envelope come with the base class. The three protected methods are required: page size limits differ per endpoint, so each subclass states its own.

Some collections page by cursor — rulesets and list items among them — carrying a cursor rather than a total_count in result_info. Walk those with apiEachByCursor(), which takes the same arguments as apiEach(). apiEach() refuses one with a RuntimeException rather than return its first page as the whole collection.

Versioning and support

Semantic versioning. 1.0.0 declares the public API stable.

"hampel/cloudflare-api": "^1.1"

That is >=1.1.0 <2.0.0. registrations() needs 1.1, and 1.0.0 and 1.0.1 carry bugs fixed since — see the CHANGELOG. Write ^1.1, not ~1.1.0 — the tilde means >=1.1.0 <1.2.0, which resolves only patch releases.

  • PHP 8.3 or later. Tests run on 8.3 (including at the lowest resolvable dependency set) and 8.5, and static analysis covers every version between. Guzzle 7 and 8 are both tested.
  • 1.x is supported. Fixes land on the current minor.
  • 0.x is not. See the CHANGELOG for the two changes between 0.1.2 and 1.0.0.

What "stable" covers, and the one place it deliberately does not

A breaking change to a class, method or method signature in src/ means 2.0.0.

RecordType is the exception. It mirrors Cloudflare's own list of record types, which this package does not control, so a type Cloudflare adds appears here in a minor release.

Every match over RecordType needs a default arm. Without one, a new Cloudflare record type is a fatal UnhandledMatchError.

Until that minor lands, a record of the new type reads with $type as null rather than as some other type. It can be read — raw holds everything Cloudflare sent — and cannot be written back, because where its value lives, whether it can be proxied and whether it has a priority are all properties of the type.

CaaTag, ZoneStatus, ZoneType and TokenStatus behave the same way: an unrecognised value is null, never a guess and never an exception.

Cloudflare's payloads: the container is stable, the contents are not

Applies to Entity::$raw, DnsRecord::$data, ApiResponse::$envelope and the numeric codes on ApiError.

Covered by the major version:

  • ApiException::$errors exists on every subclass, is public readonly, and is always a list<ApiError>[] when the response carried no errors or was not JSON at all. Never null.
  • likewise $statusCode (int), $body (string, the raw response) and $retryAfter (?int).
  • ApiError::$code (int), $message (string), $pointer and $documentationUrl (?string).
  • $raw exists on every entity and is an array<string, mixed>; DnsRecord::$data likewise.

Not covered: what is inside $raw, $data and $envelope, which numeric code Cloudflare uses for which failure, and the values Registration::$status and $privacyMode take. Those are Cloudflare's payload, passed through with no reshaping beyond dropping entries of the wrong type. A field renamed inside a record's data does not produce a major here. Read them with ??:

if ($e instanceof ValidationException) {
    foreach ($e->errors as $error) {
        $log->warning($error->message, ['code' => $error->code, 'field' => $error->field()]);
    }
}

This package promises the shape it built, not the shape Cloudflare sent. Where the two meet — an enum of Cloudflare's record types, a data object of Cloudflare's components — the container is covered by the major version and the contents are not.

Licence

MIT. See LICENSE.md.