nckrtl / cloudflare-cache
Cloudflare edge caching for Laravel: Cache-Control middleware, URL purge, warming, and Inertia-safe document caching.
Fund package maintenance!
Requires
- php: ^8.4
- illuminate/console: ^12.0||^13.0
- illuminate/contracts: ^12.0||^13.0
- illuminate/http: ^12.0||^13.0
- illuminate/support: ^12.0||^13.0
Requires (Dev)
- larastan/larastan: ^3.9
- laravel/pao: ^1.0
- laravel/pint: ^1.29
- orchestra/testbench: ^10.0||^11.0
- pestphp/pest: ^4.6||^5.0
- pestphp/pest-plugin-laravel: ^4.1||^5.0
- pestphp/pest-plugin-type-coverage: ^4.0||^5.0
- phpstan/extension-installer: ^1.4
README
Easy Cloudflare edge caching for Laravel apps:
- Middleware that sets safe
Cache-Controlheaders for public pages - Purge one URL, many URLs, or an entire zone (sync or queued)
- Optional warming after purge
- Model trait to purge when Filament / Eloquent content changes
Designed for brochure/marketing sites behind Cloudflare — including Laravel Cloud’s Cloudflare edge when you manage the zone yourself for per-URL purge.
Installation
composer require nckrtl/cloudflare-cache
Publish config (optional):
php artisan vendor:publish --tag=cloudflare-cache-config
Environment
CLOUDFLARE_CACHE_ENABLED=true CLOUDFLARE_CACHE_ENVIRONMENTS=production,staging CLOUDFLARE_ZONE_ID=your-zone-id CLOUDFLARE_API_TOKEN=your-scoped-token # Optional CLOUDFLARE_API_BASE_URL=https://api.cloudflare.com/client/v4 CLOUDFLARE_CACHE_S_MAXAGE=3600 CLOUDFLARE_CACHE_MAX_AGE=0 # 0 disables stale serving — keep at 0 unless you've weighed how long a poisoned # or broken response could stay servable at the edge before purge/re-fetch. CLOUDFLARE_CACHE_STALE_WHILE_REVALIDATE=0 CLOUDFLARE_CACHE_STALE_IF_ERROR=0 CLOUDFLARE_CACHE_QUEUE= CLOUDFLARE_CACHE_PURGE_ASYNC=true # Soft-fail (log instead of throw) when credentials are missing or the API call fails. CLOUDFLARE_CACHE_SOFT_FAIL=true CLOUDFLARE_CACHE_WARM_ENABLED=true CLOUDFLARE_CACHE_WARM_AFTER_PURGE=false CLOUDFLARE_CACHE_WARM_TIMEOUT=15 CLOUDFLARE_CACHE_WARM_USER_AGENT="Nckrtl-CloudflareCache/1.0 (+https://github.com/nckrtl/cloudflare-cache)"
Create a Cloudflare API token with Zone → Cache Purge limited to the site zone.
Cacheable routes (critical)
Cloudflare will not cache responses that set cookies. Do not put this middleware only on the default web stack.
With Waymaker (recommended)
Keep a single Waymaker-generated routes file. Opt pages into a cookie-free stack with a middleware group:
// bootstrap/app.php use NckRtl\CloudflareCache\Support\StaticMiddleware; use NckRtl\Waymaker\Facades\Waymaker; return Application::configure(basePath: dirname(__DIR__)) ->withRouting( web: __DIR__.'/../routes/web.php', // optional non-Waymaker web routes commands: __DIR__.'/../routes/console.php', health: '/up', then: function () { // Load Waymaker outside the forced `web` wrapper so `static` is top-level Waymaker::routes(); }, ) ->withMiddleware(function (Middleware $middleware) { $middleware->group('static', StaticMiddleware::defaults([ \App\Http\Middleware\HandleInertiaRequests::class, // CSP / other cookie-free middleware… ])); }) ->create();
// app/Http/Controllers/ProjectsController.php use NckRtl\Waymaker\Get; class ProjectsController extends Controller { public static string $middlewareGroup = 'static'; #[Get(uri: '/', name: 'projects')] public function index(): Response { ... } }
Per-route override (same controller can mix groups):
#[Get(uri: '/pricing', name: 'pricing', middlewareGroup: 'static')] public function pricing(): Response { ... } #[Get(uri: '/account', name: 'account', middlewareGroup: 'web', middleware: 'auth')] public function account(): Response { ... }
StaticMiddleware::defaults() is:
SubstituteBindings- Your
$beforestack (Inertia share, etc.) CacheResponse(this package)- Your
$afterstack
Requires Waymaker with middlewareGroup support (see Waymaker changelog / docs).
Without Waymaker
Alias is also registered for classic routes:
Route::middleware('static')->group(function () { Route::get('/privacy', ...)->name('privacy'); }); // or Route::get('/pricing', ...)->middleware('cloudflare.cache:86400');
Default headers:
Cache-Control: public, max-age=0, s-maxage=3600, stale-while-revalidate=0, stale-if-error=0
Middleware skips: non-GET, non-2xx, authenticated users, requests with an active session (web group), Set-Cookie responses, disabled env / package.
Cloudflare dashboard
For HTML to be eligible, ensure a Cache Everything (or equivalent) rule with Browser TTL: Respect Origin and Edge TTL: Use cache-control header from origin (Respect Origin). Without the Edge TTL setting, Cloudflare may ignore s-maxage and fall back to its own default. Static extensions are cached by Cloudflare by default.
Inertia + SSR (Cloudflare only)
Document visits and Inertia navigations share the same URL:
| Request | Expects | Edge cache |
|---|---|---|
| Full page load | SSR HTML | Yes — public, s-maxage=… |
Inertia XHR (X-Inertia: true) |
JSON | No — private, no-store |
Cloudflare free/pro does not vary the cache key on X-Inertia. Relying on Vary alone will serve HTML to Inertia navigations — store-side headers alone aren't enough either, so you also need lookup-side Cache Rules on a zone that actually proxies traffic.
This package:
- Edge-caches document visits only
- Forces
private, no-storewhenX-Inertiais present - Still sets
Vary: Accept-Encoding, X-Inertiafor correctness elsewhere
Required Cloudflare Cache Rules
Create two Cache Rules, in this order:
-
Bypass Cache when it's an Inertia navigation:
any(http.request.headers["x-inertia"][*] == "true") -
Cache Everything for other eligible
GETs (the document HTML), with:- Browser TTL: Respect Origin
- Edge TTL: Use cache-control header from origin (Respect Origin)
The Edge TTL setting matters — without it Cloudflare may ignore
s-maxagefrom this package's headers and fall back to its own default Edge TTL instead.
not len(http.request.headers["x-inertia"]) > 0 is not a valid substitute for rule 1 — len() does not operate on an array-valued header field and won't validate.
DNS for the site must be proxied (orange cloud) on that zone. Grey-cloud DNS (DNS only) means your Cache Rules never run — traffic hits Laravel Cloud’s managed edge instead, which cannot set per-header bypass rules.
Warming fetches document HTML only (no X-Inertia header).
There is no second origin cache in this package — only Cloudflare edge headers, purge, and warm.
Purge
use NckRtl\CloudflareCache\Facades\CloudflareCache; CloudflareCache::purge('https://example.com/'); CloudflareCache::purge([ 'https://example.com/', 'https://example.com/projecten/sion', ]); // Sync (CLI / tests) CloudflareCache::purge($urls, async: false); // Purge then warm CloudflareCache::purge($urls, warm: true); // Whole zone CloudflareCache::purgeEverything();
Eloquent / Filament
use NckRtl\CloudflareCache\Concerns\PurgesCloudflareCache; use NckRtl\CloudflareCache\Contracts\PurgesCloudflareUrls; use Illuminate\Database\Eloquent\Model; class PortfolioItem extends Model implements PurgesCloudflareUrls { use PurgesCloudflareCache; public function cloudflareCacheUrls(): array { return array_values(array_filter([ route('projects', absolute: true), route('portfolio-item', $this->slug, absolute: true), $this->wasChanged('slug') ? route('portfolio-item', $this->getOriginal('slug'), absolute: true) : null, ])); } }
Or call from Filament:
protected function afterSave(): void { CloudflareCache::purge($this->record, warm: true); }
Artisan
php artisan cloudflare-cache:purge https://example.com/ https://example.com/studio php artisan cloudflare-cache:purge --all --sync php artisan cloudflare-cache:purge https://example.com/ --sync --warm php artisan cloudflare-cache:warm https://example.com/ --sync
Warming
Off after purge by default (CLOUDFLARE_CACHE_WARM_AFTER_PURGE=false). Enable per call with warm: true, or set the env flag globally. Warming issues cookie-less GET requests so the edge can re-fill quickly after invalidation.
Laravel Cloud note
Laravel Cloud’s built-in edge purge API clears the whole environment. This package targets your Cloudflare zone for precise URL purge (Filament saves). Use both: deploy purge from Cloud, content purge from this package.
If the custom domain is DNS-only to Laravel Cloud, HTML may still be edge-cached by Cloud’s Cloudflare when you send public, s-maxage. Without Cache Rules you control, Inertia navigations will get that HTML — either orange-cloud through your zone with the rules above, or stop sharing HTML at the edge (private) for those pages.
Testing
composer test:unit
composer test
License
MIT