laravel-cloudflare maintained by beziworld
Laravel Cloudflare
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.