beziworld / laravel-cloudflare
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.
Requires
- php: ^8.3
- ext-json: *
- guzzlehttp/guzzle: ^7.8
- illuminate/cache: ^12.0|^13.0
- illuminate/console: ^12.0|^13.0
- illuminate/contracts: ^12.0|^13.0
- illuminate/http: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
- illuminate/validation: ^12.0|^13.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.18
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^3.7|^4.0
- pestphp/pest-plugin-laravel: ^3.0|^4.0
- phpstan/phpstan-strict-rules: ^2.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-04 16:52:41 UTC
README
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.
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 defaultzone_id - Resources:
zones,dns,cache,ssl,rulesets,accessRules,pageRules,workerRoutes, plusips(),verifyToken(),accounts()and genericapi()/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 aRateLimitExceptionwithretryAfter() - Writes are never retried automatically
- Turnstile:
new Turnstile(action: 'login')rule or'turnstile'string rule,Turnstile::verify()facade, English and Polish messages - Trusted proxies:
TrustCloudflareProxiesmiddleware with cached/ipsranges, last-known and bundled fallbacks php artisan cloudflare:purgeandphp 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.
- E-mail: biuro@beziworld.eu
- Issues and feature requests: GitHub issues
- Security vulnerabilities: see SECURITY.md
Need DNS automation, Cloudflare onboarding for your customers or a custom Laravel project? Get in touch through beziworld.eu.
Other packages by the author
- beziworld/laravel-cpanel: cPanel UAPI and WHM API 1
- beziworld/laravel-plesk: Plesk REST and XML API
- beziworld/laravel-directadmin: DirectAdmin hosting panel
License
MIT. See LICENSE.md. This package is not affiliated with or endorsed by Cloudflare, Inc. Cloudflare and Turnstile are trademarks of Cloudflare, Inc.