cloudflare/sdk
A Laravel SDK for the Cloudflare API, making it easy to manage zones, DNS records, caches, and other Cloudflare resources straight from your PHP application with a clean, expressive, framework-friendly interface.
cloudflare/sdk is a thin, framework-agnostic wrapper around the Cloudflare v4 REST API. There is no service provider, no config file, no facade — you wire it yourself, which takes about five minutes. As of 2.0.0 the package requires PHP 8.1+ and Guzzle 7.4+, and supports both psr/http-message 1.1 and 2.0.
Install and configure
composer require cloudflare/sdk:^2.0
// config/services.php
'cloudflare' => [
'token' => env('CLOUDFLARE_API_TOKEN'),
'account' => env('CLOUDFLARE_ACCOUNT_ID'),
],
Use a scoped API token (Zone:DNS:Edit, Zone:Zone:Read, Cache Purge:Purge, etc.), never the global API key. The SDK supports both, but the global key (->/email pair) has account-wide blast radius and cannot be scoped.
Confirm you can actually install it
2.0.0 raised the floor to PHP 8.1 and moved to psr/http-message ^1.1 || ^2.0. If your app still runs 8.0, or something else in your tree pins PSR-7 to 1.x, Composer will refuse the upgrade — that is the intended signal, not a bug. Check with composer why-not cloudflare/sdk 2.0.0 before opening an issue.
The three objects you need to understand
| Object | Namespace | Role |
|---|---|---|
| Auth | Cloudflare\API\Auth\APIToken / APIKey |
Credential |
| Adapter | Cloudflare\API\Adapter\Guzzle |
HTTP transport, base URI, error mapping |
| Endpoint | Cloudflare\API\Endpoints\* |
One class per API area: Zones, DNS, ZoneSettings, PageRules, User, Accounts, TLS, LoadBalancing, Firewall |
First call
use Cloudflare\API\Adapter\Guzzle;
use Cloudflare\API\Auth\APIToken;
use Cloudflare\API\Endpoints\Zones;
use Cloudflare\API\Endpoints\DNS;
$adapter = new Guzzle(new APIToken(config('services.cloudflare.token')));
$zones = new Zones($adapter);
$dns = new DNS($adapter);
$zoneID = $zones->getZoneID('example.com'); // resolves name -> 32-char ID
$records = $dns->getAllRecords($zoneID); // array of stdClass
Where to look first
src/Endpoints/ — the endpoint classes are the documentation; each public method maps 1:1 to a Cloudflare API route, and the method signature tells you exactly which fields the API accepts.src/Adapter/Guzzle.php — the get/post/put/patch/delete methods. Anything not covered by an endpoint class is reachable here.result payload, so the Cloudflare schema is your schema.First real use case: a deploy hook that purges the zone cache after a release, or a command that upserts an A record for a dynamic IP. Both are ~15 lines.
Creating a Guzzle adapter per call means a new Guzzle client and a new TLS handshake every time. Bind once:
// app/Providers/CloudflareServiceProvider.php
$this->app->singleton(Guzzle::class, fn () => new Guzzle(
new APIToken(config('services.cloudflare.token'))
));
$this->app->bind(Zones::class, fn ($app) => new Zones($app->make(Guzzle::class)));
$this->app->bind(DNS::class, fn ($app) => new DNS($app->make(Guzzle::class)));
Then inject DNS $dns into jobs, commands, and services. Add a thin CloudflareService wrapper if you want to keep the SDK's stdClass payloads out of your domain code.
The most common day-to-day friction is that Cloudflare identifies zones by ID while your app identifies them by domain. Resolve and persist once:
class CloudflareZone
{
public function __construct(
private Zones $zones,
private string $domain,
) {}
public function id(): string
{
return Cache::rememberForever(
"cloudflare:zone:{$this->domain}",
fn () => $this->zones->getZoneID($this->domain)
);
}
}
Remember to forget that cache key on zone deletion, domain rename, or when a NotFound bubbles up.
Endpoint coverage lags the API. When you need something like cache purge, Worker routes, or Rulesets, drop to the adapter and read the PSR-7 response:
$response = $adapter->post("zones/{$zoneID}/purge_cache", [
'purge_everything' => true,
]);
$body = json_decode((string) $response->getBody(), true);
if (! ($body['success'] ?? false)) {
// $body['errors'] is an array of ['code' => int, 'message' => string]
}
Cast the body to string rather than calling getContents() — see Gotchas. This snippet is unchanged between psr/http-message 1.1 and 2.0; the interfaces are source-compatible for reading a body.
Cloudflare has no upsert verb. The reliable pattern is read-then-write, keyed on name+type:
public function upsertA(string $zoneID, string $name, string $ip, bool $proxied = true): void
{
$existing = $dns->getAllRecords($zoneID);
$match = collect($existing)->first(
fn ($r) => $r->name === $name && $r->type === 'A'
);
$match
? $dns->updateRecord($zoneID, $match->id, 'A', $name, $ip, 1, $proxied)
: $dns->addRecord($zoneID, 'A', $name, $ip, 1, $proxied);
}
In multi-tenant SaaS flows, this runs on every custom-domain onboarding — put it in a queued job, and handle the race where two workers try to create the same record (Cloudflare returns error 81057, "record already exists" — treat it as success and re-read).
Custom-domain onboarding is the canonical heavyweight workflow:
Zones::addZone($domain, $accountID) — the account argument is mandatory on modern API versions; passing only the name fails.Zones::zoneDetails($zoneID) until status moves from pending to active. This takes minutes to hours, so it belongs in a scheduled job, not a request.name_servers from the zone payload and surface them to the customer.Model this as a state machine on your own record (pending_ns, active, failed) rather than blocking on Cloudflare.
// targeted purge is cheaper and safer than purge_everything
$adapter->post("zones/{$zoneID}/purge_cache", ['files' => $urls]);
Fire this from a DeployCompleted listener, wrapped in retry(3, 1000) and dispatched ->afterResponse() so a Cloudflare outage never fails a deploy.
Two options, both worth knowing:
Cloudflare\API\Adapter\Adapter (five methods: get/post/put/patch/delete) as an in-memory stub and bind it in your test case. This is version-proof and fast.Guzzle adapter accepts an optional Guzzle client as a third constructor argument, so you can inject a MockHandler stack and assert on the outgoing request URI and JSON body. With 2.0.0 you get the full Guzzle 7.4+ API — including HandlerStack, middleware, and history — which makes this much nicer than it used to be.Prefer #1 for unit tests of your domain logic, #2 for one contract test per endpoint you depend on. The Adapter interface itself did not change in 2.0.0, so a stub written against 1.x still compiles; just make sure your test suite runs on PHP 8.1+ (phpunit ^9 is what CI now uses).
The adapter throws on non-2xx responses. Wrap Cloudflare calls in a single retry helper:
try {
return retry(3, fn ($n) => $attempt(), [1000, 5000, 15000]);
} catch (\Throwable $e) {
Log::warning('cloudflare.failed', ['message' => $e->getMessage()]);
throw $e;
}
For high-volume work (bulk record migration, per-tenant provisioning), put RateLimited::perMinute(200) on the job — Cloudflare's global limit is 1200 requests per five minutes per user, and it is easy to blow through it with a foreach over 500 zones.
Upgrading from 1.x is a PHP-floor question, not an API question. 2.0.0 is a major bump primarily because the minimum PHP moved from 7.2.5 to 8.1. There is no renamed endpoint or changed method signature in the release notes. In a Laravel app, that means your gate is composer.json's php constraint and whatever else pins the runtime — Laravel 9/Sail images on PHP 8.1+ are fine, older homestead boxes are not. Do the upgrade on a branch, run your contract tests, and expect zero code changes if you were already on 8.1.
psr/http-message 2.0 is a win, not a hazard. The new ^1.1 || ^2.0 constraint resolves the classic conflict with packages that had already adopted PSR-7 v2. If you had a conflict or a fork pinning this, drop it. The interfaces you touch (getBody(), getStatusCode()) are identical for reading.
getContents() is single-use. If you read the PSR-7 body twice you get an empty string, silently. Always (string) $response->getBody(), which rewinds first. This bites hardest when you log a response and then parse it.
Pagination is not handled for you. getAllRecords() and the zone listing methods return one page. Cloudflare caps page size (50 for zones, 100 for DNS records) and silently ignores a per_page of 1000. For anything over a page, loop on page until a page comes back short — or accept that your "list all records across all zones" script quietly misses data.
Eventually consistent zone creation. getZoneID() immediately after addZone() frequently returns not-found, and zoneDetails() returns pending with name_servers unset. Never assume synchronous creation.
Endpoint methods return false on some failures. Depending on version and method, a failed create/update can return false rather than throwing — the adapter may or may not have thrown first. Check the return value explicitly for write operations, and don't rely solely on try/catch.
proxied and ttl interact. A proxied record only accepts ttl of 1 (automatic), and Cloudflare rejects priority on record types that don't use it. Passing a priority of 0 for an A record is usually tolerated; a non-zero one is not. Validate before sending or you'll get opaque 400s.
Error codes live in the body, not the exception code. $e->getCode() gives you HTTP status; the actionable Cloudflare code is errors[0].code in the JSON body. Log both. The handful you will actually branch on: 81057 record exists, 1049 unknown zone, 1004 invalid/malformed, 9109 unauthorized for scope, 10000 auth error.
Token scoping failures look like 403s, not 401s. A token that lacks Cache Purge:Purge will authenticate fine and fail only on the purge call. When a single endpoint mysteriously 403s while the rest of your code works, check the token's permission list before you check the code.
getZoneID() throws rather than returning null when the zone doesn't exist. That's fine in a request path but will explode a foreach over thousands of domains — wrap per-domain calls individually in bulk jobs.
Pass email + key in the wrong order and you get a confusing 400. new APIKey($email, $key). Prefer APIToken and avoid the ambiguity entirely.
Base URI and trailing slashes. The adapter composes https://api.cloudflare.com/client/v4/ with the relative path. If you call setBaseURI(), keep the trailing slash. It's also your hook for pointing at a local stub server in integration tests.
Never log the token, and never include a raw request dump in an exception message. Guzzle's debug/verbose output includes the Authorization header. If you enable debug on the underlying client, route it to a channel that redacts headers.
Extension point: endpoint classes are cheap. If you need a resource the SDK hasn't wrapped, subclass or just write a small class that takes the Adapter in its constructor and calls $this->adapter->get('zones/'.$id.'/rulesets'). You get consistent error handling, base URI, and testability without waiting for a library release — and the class still satisfies the Adapter interface for tests.
stdClass all the way down. Records and zones come back as \stdClass, not arrays or typed models. collect($records) gives you Collection ergonomics; converting to DTOs at the boundary keeps your app from depending on Cloudflare's field names everywhere.
security_level on the zone is your incident-response lever. ZoneSettings::updateSetting($zoneID, 'security_level', 'under_attack') is the fastest way to mitigate an active attack from a Laravel admin panel — worth wiring to an artisan command before you need it, and worth a scheduled revert, since forgetting to turn it off degrades normal traffic.
Version pinning matters more than usual here. Signature changes have landed across major versions (account IDs became mandatory for zone creation; several classes moved or were removed), and 2.0.0 itself removes PHP 7.x/8.0 support and drops the committed composer.lock. Pin ^2.0 in composer.json, keep php and guzzlehttp/guzzle (^7.4) in step with it, and cover your integration points with the contract tests described above so an upgrade surfaces breakage in CI rather than in production.
How can I help you explore Laravel packages today?