Search by

hampel / cloudflare-api

hampel

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

Package info

github.com/hampel/cloudflare-api

pkg:composer/hampel/cloudflare-api

Statistics

Installs: 19

Dependents: 1

Suggesters: 0

Stars: 0

Open Issues: 0

0.1.2 2026-09-12 23:35 UTC

This package is auto-updated.

Last update: 2026-09-12 23:42:02 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 — 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 GuzzleHttp\Psr7\HttpFactory;
use Hampel\Cloudflare\Api\Authentication\ApiToken;
use Hampel\Cloudflare\Api\Client;
use Hampel\Cloudflare\Api\Config;
use Hampel\Cloudflare\Api\Entity\DnsRecord;

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

$cloudflare = new Client(new Config(), new ApiToken('MY-API-TOKEN'), $guzzle, $factory, $factory);

$cloudflare->verify();

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

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

The short form, when every construction question takes its default:

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

Leave the PSR-17 factories out entirely and the package finds one — Guzzle's, Nyholm's or Diactoros', whichever is installed.

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

Restrict Zone Resources to the zones the token needs.

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 of the two ways Cloudflare refuses it. See below.

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->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();

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.

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. A size outside the range is refused before the request is 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 or revoked
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 two 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. 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

Both raise NotAuthenticatedException. The second is the likelier failure in practice, and the only one whose status suggests the request was at fault rather than the credential — so catching ValidationException for it, as the status invites, sends you to inspect a payload that is fine.

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 null. It does not absorb a 403 carrying code 10000 — "Authentication error", a token whose permissions or resources do not cover the zone — because reporting a misconfigured credential as "the zone does not exist" sends whoever chases it to the wrong place.

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 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 Firewall extends Endpoint
{
    public function rules(string $zoneId): \Generator
    {
        return $this->apiEach('zones/' . $zoneId . '/firewall/rules', static fn (array $row) => $row);
    }

    protected function minimumPageSize(): int { return 1; }
    protected function maximumPageSize(): int { return 500; }
    protected function collectionName(): string { return 'firewall rules'; }
}

$cloudflare->endpoint(Firewall::class)->rules($zoneId);

There is nothing to register. Pagination, error handling and the envelope come with the base class.

Versioning and support

Semantic versioning. PHP 8.3 and up.

This is a 0.x release: the public API is not yet declared stable. Write ^0.1.1, which accepts 0.1.1 and later and stops short of 0.2.0 — under Composer a caret constraint on a 0.x version treats the minor as the breaking position, so a caret here is the 0.x equivalent of ^1.0, and ~0.1 is not.

Floor it at 0.1.1 rather than 0.1.0: the earlier release routes a credential Cloudflare cannot parse to ValidationException, contradicting the error documentation above.

Adding a case to RecordType is a breaking change, because an exhaustive match over it in a consumer would start throwing. Write a default arm.

The raw property on every entity is the payload it was built from, so a field added to the API after a release is reachable without waiting for one. Its contents are Cloudflare's to change.

Licence

MIT. See LICENSE.md.