jamesryanbell/cloudflare
A PHP wrapper for the Cloudflare API that lets you manage zones, DNS records, caching, and other Cloudflare resources from Laravel or plain PHP applications using simple, expressive methods.
Install and wire up
composer require jamesryanbell/cloudflare
On Laravel 5.5+ auto-discovery registers the service provider and the Cloudflare facade for you. On older versions add the provider to config/app.php and alias the facade manually (namespace is JamesRyanBell\Cloudflare\...).
Publish the config and fill in credentials:
php artisan vendor:publish --provider="JamesRyanBell\Cloudflare\CloudflareServiceProvider"
config/cloudflare.php should hold your account email and API key (see Gotchas — this package predates scoped API tokens).
Where to look first
config/cloudflare.php after publishing — confirm the key names match what the provider actually reads (email / api_key); if it doesn't work, the package's source config is the source of truth.src/ directory — the resource classes (Zone, DNS, User, Api) are small; read the method signatures before guessing them. Because the package is archived, the code is the documentation.php artisan tinker — fastest way to see the actual response shape:>>> Cloudflare::zones()->all() // or ->list(); check the class
First real use case
Pull a zone, then read and create a DNS record:
use Cloudflare;
$zones = Cloudflare::zones()->all();
$zoneId = $zones[0]->id; // confirm key name in tinker first
Cloudflare::dns()->all($zoneId);
Cloudflare::dns()->create($zoneId, 'A', 'app.example.com', '203.0.113.10', 1, false);
Get that loop working in tinker before you build a service class around it — response shapes and argument order vary between endpoint wrappers.
Wrap the facade in your own service, not the other way around
Don't sprinkle Cloudflare:: calls through controllers. Create a thin CloudflareDnsService that takes the resource objects (or the facade) and exposes domain-meaningful methods:
class DnsService {
public function pointSubdomainTo(string $zoneId, string $host, string $ip): void { ... }
public function removeRecord(string $zoneId, string $recordId): void { ... }
}
This gives you one place to add logging, retries, and — critically for an archived package — one place to swap the implementation out later.
Store zone and record IDs
Cloudflare's API is ID-driven; every DNS operation needs the record id. Don't re-list on every request. Persist zone_id on your Site/Tenant model and cloudflare_record_id on whatever owns the record. A zones() lookup on every page render is both slow and rate-limit-hostile.
Provisioning pattern (multi-tenant SaaS)
Classic use case: new tenant signs up → create tenant.example.com:
DB::transaction(function () use ($tenant) {
$record = $this->dns->create($zoneId, 'A', $tenant->slug . '.example.com', $ip);
$tenant->update(['cf_record_id' => $record->id]);
});
Do the API call before committing, or record the ID and clean up on failure. Never leave an orphan record because a DB write threw afterward.
Dynamic DNS / scheduled updates
A scheduled command that checks the server's public IP and edits a single A record is the lowest-friction day-to-day use. Use edit with the stored record ID — not create — otherwise you accumulate duplicate records on every run.
Bulk operations belong in a queue
Creating 200 subdomains in a request will hit Cloudflare's rate limits (~1,200 requests / 5 minutes per user, and lower per-zone limits) or your PHP max_execution_time. Fan out to a job per record, add ->onQueue('dns')->delay(...) spacing, and make the job idempotent (look up by name before creating).
Testing
The facade is mockable, which is the main reason to keep using it rather than raw Guzzle:
Cloudflare::shouldReceive('dns->create')
->once()
->andReturn((object) ['id' => 'rec_123']);
Better: bind a DnsService interface in the container and swap in a fake for tests, so you never assert against this package's method names in your test suite.
Error handling
Responses are PSR-friendly, so catch transport exceptions at the service boundary and translate them:
try {
$result = Cloudflare::dns()->create(...);
} catch (\Exception $e) {
Log::error('Cloudflare DNS create failed', ['host' => $host, 'e' => $e->getMessage()]);
throw new DnsProvisioningException($host, previous: $e);
}
Also inspect the body — Cloudflare returns HTTP 200 with success: false and an errors array for many validation failures. A no-throw does not mean success.
It is archived (last release 2017). Treat it as frozen risk.
Cloudflare's v4 API has grown considerably since 2017 (API tokens, newer record types, additional zone settings). The wrapper still speaks the old surface. Budget for a migration to the official cloudflare/sdk or a hand-rolled Guzzle client if you depend on anything beyond basic zone + DNS CRUD.
API token auth is almost certainly not supported.
The description mentions "API token / email-and-key authentication," but this package predates Cloudflare API tokens (2019). Expect it to require the global API key + account email, which is far more dangerous — it grants full account access. If your security posture forbids global keys, this package is not viable without a fork.
Config caching bites silently.
php artisan config:clear # after editing .env
php artisan config:cache # re-cache for prod
A stale cached config is the #1 cause of "it works in tinker but not in the app / queue worker."
Create is not idempotent.
Calling create twice makes two identical records. Always all($zoneId) and filter by name/type first, or store the record ID and use edit. Idempotency is your responsibility, not the wrapper's.
Pagination is manual.
Listing large zones returns a page at a time. Pass page/per-page parameters explicitly and loop until you've seen everything — don't assume all() returns the full zone.
Record field quirks.
1 means "automatic." Don't send 0.proxied (the orange cloud) is a boolean per record — getting its position in the create() argument list wrong will silently produce an unproxied record. Verify in the dashboard the first time.Debugging a failed call.
Log the response body (success, errors[].message) — Cloudflare's messages are usually precise ("record already exists", "invalid zone identifier"). Also capture the cf-ray header from the response; Cloudflare support and the dashboard's audit log key off it.
Guzzle version conflicts.
A 2017 package pins an old Guzzle. On Laravel 8+/PHP 8 you may hit dependency resolution or runtime incompatibilities. Composer --ignore-platform-reqs is not a fix; check composer why guzzlehttp/guzzle before blaming your code.
Extension points if you must keep it.
$this->app->bind('cloudflare', fn () => new YourAdapter()) — lets you keep the facade calls but back them with a modern client.Api class to accept a bearer token instead of email+key; the HTTP layer is thin enough that this is a small, contained change.How can I help you explore Laravel packages today?