-
-
Notifications
You must be signed in to change notification settings - Fork 600
Description
Bug description
When using static caching with the half strategy (application caching), the middleware returns HTTP 304 (Not Modified) responses to clients that haven't sent any cache validation headers (If-None-Match or If-Modified-Since). This causes browsers like Safari to download a 0-byte file instead of rendering the page.
Expected behavior: Requests without cache validation headers should receive HTTP 200 with full content.
Actual behavior: Requests without cache validation headers receive HTTP 304 with no content, causing Safari to download 0 bytes instead of rendering the page.
How to reproduce
- Enable static caching:
STATAMIC_STATIC_CACHING_STRATEGY=halfin.env - Visit a page multiple times to populate the application cache
- Clear browser cache or visit from a different browser/client
- Make a request without cache validation headers:
curl -I https://example.com - Observe inappropriate HTTP 304 response instead of HTTP 200
- In Safari, the page will download as a 0-byte file instead of rendering
Temporary workaround: Running php artisan cache:clear resolves the issue until cache state becomes corrupted again.
Environment
Environment
Application Name: [Redacted]
Laravel Version: 12.47.0
PHP Version: 8.4.16
Composer Version: 2.9.3
Environment: production
Debug Mode: OFF
URL: [redacted]
Maintenance Mode: OFF
Timezone: UTC
Locale: de
Cache
Config: CACHED
Events: CACHED
Routes: CACHED
Views: CACHED
Drivers
Broadcasting: log
Cache: file
Database: mysql
Logs: stack / single
Mail: smtp
Queue: redis
Session: file
Storage
public/storage: NOT LINKED
Statamic
Addons: 1
Sites: 1
Stache Watcher: Disabled (auto)
Static Caching: half
Version: 5.72.0 Solo
Statamic Addons
statamic-rad-pack/runway: 8.9.0
Installation
Existing Laravel app
Root Cause Analysis
The issue is in vendor/statamic/cms/src/StaticCaching/Middleware/Cache.php at lines 235-244:
private function addEtagToResponse($request, $response)
{
if (! $response->isRedirect() && $content = $response->getContent()) {
$response
->setEtag(md5($content))
->isNotModified($request); // ← Called unconditionally
}
return $response;
}
Problem: isNotModified() is called on every cached response, regardless of whether the request contains cache validation headers.
Hypothesis: When ApplicationCacher caches a response (ApplicationCacher.php line 48-50), it stores headers but only excludes:
->reject(fn ($value, $key) => in_array($key, ['date', 'x-powered-by', 'cache-control', 'expires', 'set-cookie']))
Notice that etag and last-modified are not excluded. If a cached response has these headers from a previous request, they may interfere with fresh ETag generation and cause isNotModified() to incorrectly return true.
Context
This bug is related to the ETag support feature introduced in:
- v5.68 (October 29, 2025): Initial ETag implementation via PR [5.x] Set etags #11441
- v5.70 (January 13, 2026): Fix for ETag/nocache incompatibility (Etag support introduced in v5.68 is not compatible with nocache tags #13425)
This appears to be a new edge case in the ETag implementation that differs from #13425.
Suggested Fix
Option 1: Only call isNotModified() when request has cache validation headers:
private function addEtagToResponse($request, $response)
{
if (! $response->isRedirect() && $content = $response->getContent()) {
// Clear potentially stale headers
$response->headers->remove('ETag');
$response->headers->remove('Last-Modified');
// Set fresh ETag
$response->setEtag(md5($content));
// Only call isNotModified() if request has cache validation headers
if ($request->headers->has('If-None-Match') || $request->headers->has('If-Modified-Since')) {
$response->isNotModified($request);
}
}
return $response;
}
Option 2: Exclude etag and last-modified from cached headers in ApplicationCacher.php line 49:
->reject(fn ($value, $key) => in_array($key, [
'date', 'x-powered-by', 'cache-control', 'expires', 'set-cookie',
'etag', 'last-modified' // Add these
]))
Workaround
We've implemented a custom middleware that wraps Statamic's caching logic with the fix from Option 1. Tested and verified:
- ✅ Request without cache headers → 200 OK
- ✅ Request with If-None-Match → 304 Not Modified
- ✅ ETag functionality preserved for legitimate conditional requests