Search by

beziworld / laravel-cloudflare

kamcw

Laravel client for the Cloudflare API v4: zones, DNS records, cache purge, SSL/TLS, rulesets, IP access rules, Workers routes, Turnstile validation and trusted proxy IP ranges.

Package info

github.com/Beziworld/laravel-cloudflare

Homepage

Issues

pkg:composer/beziworld/laravel-cloudflare

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

v1.0.0 2026-11-04 16:52 UTC

This package is auto-updated.

Last update: 2026-11-04 16:52:41 UTC


README

tests Latest Version License

Laravel client for the Cloudflare API v4: zones and settings, DNS records (including batch, BIND import and export), cache purge, SSL/TLS, WAF custom rules, IP access rules, Workers routes, plus a Turnstile validation rule, a trusted proxies middleware with cached Cloudflare IP ranges and a cloudflare:purge command. Works with any number of Cloudflare accounts.

Polska wersja dokumentacji

Features

  • Multiple named connections (one per Cloudflare account) plus Cloudflare::using([...]) for tokens stored in your DB
  • API token (Authorization: Bearer) or legacy Global API Key (X-Auth-Email + X-Auth-Key)
  • Zones referenced by ID, by name ('example.com', resolved once) or by the connection's default zone_id
  • Resources: zones, dns, cache, ssl, rulesets, accessRules, pageRules, workerRoutes, plus ips(), verifyToken(), accounts() and generic api() / request() for every other endpoint
  • Automatic pagination of list endpoints; typed, immutable DTOs (Zone, DnsRecord, IpRanges, TurnstileResult)
  • Envelope errors become exceptions with Cloudflare error codes (hasErrorCode(81057)); HTTP 429 becomes a RateLimitException with retryAfter()
  • Writes are never retried automatically
  • Turnstile: new Turnstile(action: 'login') rule or 'turnstile' string rule, Turnstile::verify() facade, English and Polish messages
  • Trusted proxies: TrustCloudflareProxies middleware with cached /ips ranges, last-known and bundled fallbacks
  • php artisan cloudflare:purge and php artisan cloudflare:ips --refresh
  • PHP 8.3+, Laravel 12 and 13, PHPStan level 8

Installation

composer require beziworld/laravel-cloudflare
php artisan vendor:publish --tag=cloudflare-config   # optional
CLOUDFLARE_API_TOKEN=your-api-token          # My Profile > API Tokens, scoped to the needed zones
CLOUDFLARE_ACCOUNT_ID=01a7362d577a6c3019a474fd6f485823   # needed to create zones and account IP rules
CLOUDFLARE_ZONE_ID=023e105f4ecef8ad9ca31a8372d0c353      # optional default zone

TURNSTILE_SITE_KEY=0x4AAAAAAA...
TURNSTILE_SECRET_KEY=0x4AAAAAAA...

Usage examples

Turnstile on a form

<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>

<form method="POST" action="/login">
    @csrf
    <div class="cf-turnstile" data-sitekey="{{ config('cloudflare.turnstile.site_key') }}" data-action="login"></div>
    <button>Log in</button>
</form>
use Beziworld\Cloudflare\Rules\Turnstile;

$request->validate([
    'email' => ['required', 'email'],
    'cf-turnstile-response' => ['required', new Turnstile(action: 'login')],
]);

// or as a string rule with optional action and hostname: 'turnstile', 'turnstile:login', 'turnstile:login,example.com'

The rule sends the visitor IP address ($request->ip()) and fails closed when Siteverify cannot be reached. Without the rule:

use Beziworld\Cloudflare\Facades\Turnstile;

$result = Turnstile::verify($request->input('cf-turnstile-response'), $request->ip());
$result->success; $result->errorCodes; $result->hostname; $result->action;

In automated tests use the official test secret keys: 1x0000000000000000000000000000000AA always passes, 2x0000000000000000000000000000000AA always fails and 3x0000000000000000000000000000000AA returns timeout-or-duplicate.

Real visitor IP behind Cloudflare (trusted proxies)

// bootstrap/app.php
use Beziworld\Cloudflare\Http\Middleware\TrustCloudflareProxies;
use Illuminate\Http\Middleware\TrustProxies;

->withMiddleware(function (Middleware $middleware): void {
    $middleware->replace(TrustProxies::class, TrustCloudflareProxies::class);
})

$request->ip() then returns the visitor address from X-Forwarded-For only when the request really comes from a Cloudflare edge address. The ranges from GET /ips are cached for a day (cloudflare.trusted_proxies.cache_ttl); when Cloudflare cannot be reached, the last fetched list or the snapshot bundled with the package is used. Refresh them from the scheduler:

Schedule::command('cloudflare:ips --refresh')->daily();

Cache purge after a deployment or content change

use Beziworld\Cloudflare\Facades\Cloudflare;

Cloudflare::cache()->purgeEverything('example.com');
Cloudflare::cache()->purgeUrls('example.com', [route('products.show', $product)]);  // split into chunks of 30
Cloudflare::cache()->purgeTags(null, ['product-' . $product->id]);
Cloudflare::cache()->purgeHosts(null, ['images.example.com']);
Cloudflare::cache()->purgePrefixes(null, ['example.com/blog/']);
php artisan cloudflare:purge example.com --everything --force
php artisan cloudflare:purge --url=https://example.com/ --tag=home --connection=client2

DNS records

use Beziworld\Cloudflare\Data\DnsRecord;

$dns = Cloudflare::dns();

$record = $dns->add('example.com', 'A', 'shop', '192.0.2.10', proxied: true);
$dns->add('example.com', 'MX', 'example.com', 'mail.example.com', priority: 10);
$dns->patch('example.com', $record->id, ['content' => '192.0.2.11']);
$dns->update('example.com', $record->id, new DnsRecord('A', 'shop', '192.0.2.12', ttl: 300, proxied: false));
$dns->delete('example.com', $record->id);

foreach ($dns->findByName('example.com', 'TXT', '_acme-challenge.example.com') as $old) {
    $dns->delete('example.com', $old->id);
}

// Atomic batch: deletes, patches, puts, posts
$dns->batch('example.com', posts: [new DnsRecord('TXT', '_acme-challenge', $token)], deletes: [$oldId]);

file_put_contents('example.com.zone', $dns->export('example.com'));    // BIND
$dns->import('example.org', file_get_contents('example.org.zone'), proxied: false);

Onboarding a customer domain

$zone = Cloudflare::zones()->create('customer-shop.com');      // uses account_id of the connection
$zone->nameServers;                                            // show these to the customer

Cloudflare::ssl()->setMode($zone->id, 'strict');
Cloudflare::ssl()->alwaysUseHttps($zone->id);
Cloudflare::ssl()->minTlsVersion($zone->id, '1.2');
Cloudflare::zones()->updateSetting($zone->id, 'brotli', 'on');

Security: WAF custom rules and IP access rules

Cloudflare::rulesets()->addCustomRule('example.com', '(http.request.uri.path contains "/wp-login.php")', 'block', 'Block WordPress probes');
Cloudflare::rulesets()->rules('example.com');

Cloudflare::accessRules()->block('198.51.100.23', 'Brute force on /login', zone: 'example.com');
Cloudflare::accessRules()->challenge('AS64496', zone: 'example.com');
Cloudflare::accessRules()->allow('203.0.113.7', 'Office');      // account level when no zone and no zone_id
Cloudflare::accessRules()->deleteByValue('198.51.100.23', zone: 'example.com');

Workers routes, page rules, other endpoints

Cloudflare::workerRoutes()->create('example.com', 'example.com/api/*', 'api-worker');
Cloudflare::pageRules()->create('example.com', 'example.com/admin*', [['id' => 'cache_level', 'value' => 'bypass']]);

Cloudflare::api('GET', 'zones/' . $zoneId . '/settings/http3');
Cloudflare::request('GET', 'zones', ['per_page' => 5]);     // full envelope with result_info

Many Cloudflare accounts

// config/cloudflare.php
'connections' => [
    'default' => ['token' => env('CLOUDFLARE_API_TOKEN'), 'account_id' => env('CLOUDFLARE_ACCOUNT_ID')],
    'agency' => ['token' => env('CF_AGENCY_TOKEN'), 'account_id' => env('CF_AGENCY_ACCOUNT')],
],

Cloudflare::connection('agency')->zones()->list();

Cloudflare::using(['token' => decrypt($customer->cloudflare_token), 'zone_id' => $customer->zone_id])
    ->cache()->purgeEverything();

Exceptions

All exceptions extend CloudflareException:

Exception When
AuthenticationException Invalid, malformed or insufficient token or key (HTTP 401, codes 1000, 6003, 9103, 10000, ...). Never retried.
RateLimitException HTTP 429; retryAfter() returns the suggested delay. Never retried automatically.
ApiException "success": false, unexpected response or transport error (errors(), hasErrorCode(), operation(), httpStatus(), payload())
InvalidConfiguration Unknown connection, missing credentials, account ID, zone or Turnstile secret, unknown zone name

Notes

  • The global API rate limit is 1200 requests per five minutes per user and 200 requests per second per IP address.
  • Purges by URL, tag, host and prefix are sent in chunks of 30; when a chunk fails the rest is not sent.
  • Page Rules are deprecated by Cloudflare in favour of Rules; prefer rulesets() for new configuration.
  • R2 and GraphQL analytics are out of scope.

Development

composer install
composer ci   # pint --test, phpstan (level 8), pest

Author

Created and maintained by Kamil Ćwiertnia, beziworld.eu.

Need DNS automation, Cloudflare onboarding for your customers or a custom Laravel project? Get in touch through beziworld.eu.

Other packages by the author

License

MIT. See LICENSE.md. This package is not affiliated with or endorsed by Cloudflare, Inc. Cloudflare and Turnstile are trademarks of Cloudflare, Inc.