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.
composer require nckrtl/cloudflare-cachePublish config (optional):
php artisan vendor:publish --tag=cloudflare-cache-configCLOUDFLARE_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.
Cloudflare will not cache responses that set cookies. Do not put this middleware only on the default web stack.
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).
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=0Middleware skips: non-GET, non-2xx, authenticated users, requests with an active session (web group), Set-Cookie responses, disabled env / package.
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.
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
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.
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();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);
}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/ --syncOff 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’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.
composer test:unit
composer testMIT