gpenverne/cloudflare-bundle
A Symfony/PHP bundle that integrates the Cloudflare API into your app, making it easy to manage zones, DNS records, and cache purging from code. Useful for automating Cloudflare tasks in deployment, admin, or maintenance workflows.
First, the reality check. gpenverne/cloudflare-bundle is a Symfony bundle (GPenverne\CloudflareBundle), not a Laravel package. There is no CloudflareServiceProvider, no publishable config, no facade, and Laravel's package discovery won't touch it. It exists to register cloudflare/sdk's Adapter (a Guzzle-backed HTTP client for api.cloudflare.com/client/v4) into a Symfony DI container. Last release was January 2020 and it has essentially no adoption.
For day-to-day Laravel work, the pragmatic path is to skip the bundle and depend on the SDK it wraps:
composer require cloudflare/sdk
// config/services.php
'cloudflare' => [
'token' => env('CLOUDFLARE_API_TOKEN'), // preferred: scoped API token
'email' => env('CLOUDFLARE_EMAIL'), // legacy global key auth
'key' => env('CLOUDFLARE_API_KEY'),
'zone' => env('CLOUDFLARE_ZONE_ID'),
],
If you genuinely want the bundle (e.g. you're porting a Symfony app and want identical wiring), install it and read these files first, in this order:
DependencyInjection/Configuration.php — the real config keys (email/api_key/api_token, adapter choice). Names change across versions; don't trust blog posts.Resources/config/services.xml — the service IDs it registers (cloudflare.adapter, the GuzzleAdapter alias).GPenverneCloudflareBundle.php — confirms there's nothing beyond DI.Because a Symfony bundle's Extension::load() expects a ContainerBuilder, pulling it into Laravel means instantiating the Symfony DI component manually — more ceremony than the four lines of bindings below.
First use case: resolve a zone name to a zone ID, then list DNS records. That's 90% of what people actually do with this API.
use Cloudflare\API\Adapter\GuzzleAdapter;
use Cloudflare\API\Auth\APIToken;
use Cloudflare\API\Auth\APIKey;
use Cloudflare\API\Endpoints\Zones;
use Cloudflare\API\Endpoints\DNS;
use GuzzleHttp\Client;
// app/Providers/CloudflareServiceProvider.php
public function register(): void
{
$this->app->singleton(GuzzleAdapter::class, function () {
$client = new Client([
'base_uri' => 'https://api.cloudflare.com/client/v4/',
'timeout' => 10,
]);
$auth = config('services.cloudflare.token')
? new APIToken(config('services.cloudflare.token'))
: new APIKey(
config('services.cloudflare.email'),
config('services.cloudflare.key')
);
return new GuzzleAdapter($client, $auth);
});
$this->app->bind(Zones::class, fn ($app) => new Zones($app->make(GuzzleAdapter::class)));
$this->app->bind(DNS::class, fn ($app) => new DNS($app->make(GuzzleAdapter::class)));
}
$zoneId = app(Zones::class)->getZoneID('example.com');
$page = app(DNS::class)->listRecords($zoneId, '', '', '', 1, 100);
foreach ($page->result as $record) {
echo "{$record->type} {$record->name} -> {$record->content}\n";
}
Wrap the endpoints in one small domain service. Don't sprinkle app(DNS::class) through controllers. One class, one seam to fake in tests:
namespace App\Services;
use Cloudflare\API\Endpoints\DNS;
use Cloudflare\API\Endpoints\Zones;
use Illuminate\Support\Facades\Cache;
class CloudflareDns
{
public function __construct(
private DNS $dns,
private Zones $zones,
) {}
public function zoneId(string $domain): string
{
return Cache::rememberForever("cf:zone:{$domain}", function () use ($domain) {
$id = $this->zones->getZoneID($domain);
if (! $id) {
throw new \RuntimeException("No Cloudflare zone for {$domain}");
}
return $id;
});
}
/** Create-or-update, so repeated deploys are idempotent. */
public function upsertRecord(string $domain, string $type, string $name, string $content, int $ttl = 1, bool $proxied = false): void
{
$zoneId = $this->zoneId($domain);
$existing = $this->dns->listRecords($zoneId, $name, '', $type, 1, 1);
if ($existing->result) {
$this->dns->updateRecord($zoneId, $existing->result[0]->id, $type, $name, $content, $ttl, $proxied);
return;
}
$this->dns->addRecord($zoneId, $type, $name, $content, $ttl, $proxied);
}
public function deleteRecord(string $domain, string $recordId): void
{
$this->dns->deleteRecord($this->zoneId($domain), $recordId);
}
/** Iterate every page when you need the whole zone. */
public function allRecords(string $domain): \Generator
{
$zoneId = $this->zoneId($domain);
$page = 1;
do {
$response = $this->dns->listRecords($zoneId, '', '', '', $page, 100);
yield from $response->result;
$totalPages = $response->result_info->total_pages ?? 1;
} while ($page++ < $totalPages);
}
}
Drive it from queued jobs, not requests. DNS changes are slow, rate-limited, and failure-prone. A job with retries and backoff is the right shape:
class SyncDnsRecord implements ShouldQueue
{
use InteractsWithQueue, Queueable;
public int $tries = 5;
public array $backoff = [10, 30, 60, 120];
public function __construct(public string $domain, public array $record) {}
public function handle(CloudflareDns $cf): void
{
$cf->upsertRecord($this->domain, ...$this->record);
}
}
Artisan commands for ops workflows. A cloudflare:dns:sync {domain} command that reads a YAML/JSON manifest and diffs against allRecords() is far more useful than ad-hoc tinker calls, and it gives you --dry-run for free.
Cache purging after deploys. Same adapter, a different endpoint (Zone/Zones purgeCache). Wire it into your deploy pipeline rather than a controller:
app(\Cloudflare\API\Endpoints\Zone::class)
->purgeCache($zoneId, ['files' => ['https://example.com/app.css']]);
If you insist on the Symfony bundle: register it as a singleton by hand, bypassing the container extension — build the GuzzleAdapter from the bundle's config values yourself. You gain nothing over the provider above except a transitive dependency on old Symfony DI packages.
Dynamic DNS / edge config use case. For a self-hosted service, a scheduled command that compares the machine's public IP to the current A record and calls upsertRecord is the canonical "day two" use case. Wrap it in withoutOverlapping().
cloudflare/sdk, which in turn pins old Guzzle. Composer will happily downgrade Guzzle and break other packages. Check composer why guzzlehttp/guzzle before committing. This alone is the strongest argument for depending on cloudflare/sdk directly and keeping it current.APIToken and APIKey are different classes with different constructor signatures. Passing a scoped API token to APIKey yields 400/403 with an unhelpful body. Prefer API tokens (scoped to Zone:DNS:Edit, Zone:Zone:Read) — global keys leak the whole account.getZoneID returns false, not an exception, when the zone is absent or the token lacks access. Always check the return, as the wrapper above does.Cache::rememberForever); re-resolving per request is a wasted API call against a 1200-req/5-min budget.listRecords defaults to 20 per page — a naive foreach over one call silently truncates big zones. Read result_info->total_pages. Some endpoints use page/per_page, others cursor-based — check the endpoint class.1 means "automatic". Setting ttl to 0 or omitting it produces different results depending on endpoint version. proxied => true is only valid for A, AAAA, and CNAME, and proxied records ignore custom TTLs.success, errors[], messages[]. Enable Guzzle http_errors and wrap calls, or inspect ->success explicitly. Log the full errors array — Cloudflare's code values are the actually useful part.GuzzleHttp\Handler\MockHandler injected into the Client and assert on the request your code builds — that's the only reliable way to verify DNS payloads.LoggingAdapter (from cloudflare/sdk) wrapping GuzzleAdapter in local/staging to get request/response logging without touching your services. That's the cleanest extension point the underlying SDK offers — the bundle adds none of its own.->middleware(fn ($job) => $job->onQueue('dns')) with a single worker).How can I help you explore Laravel packages today?