Summary
Add RFC 7234 compliant HTTP caching support with intelligent cache management, ETags, Last-Modified headers, and configurable cache backends.
Motivation
HTTP caching is essential for:
- Performance: Dramatically reduce network requests and response times
- Bandwidth: Save on data transfer costs
- Reliability: Serve cached responses when services are unavailable
- User Experience: Faster application response times
- Rate Limiting: Reduce API quota consumption
- Offline Support: Enable offline-first applications
Currently, Fetch PHP has no built-in caching mechanism, forcing developers to implement custom solutions or use separate libraries.
Proposed API
// Simple caching with default settings
$response = fetch_client()
->withCache() // Uses default file cache
->get('/api/users');
// Advanced caching configuration
$response = fetch_client()
->withCache(new RedisCache($redis), [
'default_ttl' => 3600,
'respect_cache_headers' => true,
'stale_while_revalidate' => 300,
'max_stale' => 86400,
'vary_headers' => ['Authorization', 'Accept-Language'],
'cache_methods' => ['GET', 'HEAD'],
'cache_status_codes' => [200, 203, 300, 301, 410],
])
->get('/api/data');
// Per-request cache control
$response = fetch('/api/users', [
'cache' => [
'ttl' => 1800,
'key' => 'users_page_1',
'force_refresh' => false,
'private' => true,
]
]);
// Cache policies
$response = fetch_client()
->withCachePolicy([
'max-age' => 3600,
'stale-while-revalidate' => 300,
'stale-if-error' => 86400,
'must-revalidate' => false,
])
->get('/api/data');
Implementation Details
Cache Backends
// File-based cache (default)
class FileCache implements CacheInterface
{
public function __construct(
private string $directory = '/tmp/fetch-cache',
private int $defaultTtl = 3600,
private int $maxSize = 104857600 // 100MB
) {}
public function get(string $key): ?CachedResponse;
public function set(string $key, CachedResponse $response, ?int $ttl = null): void;
public function delete(string $key): void;
public function clear(): void;
public function prune(): int; // Remove expired entries
}
// Redis cache
class RedisCache implements CacheInterface
{
public function __construct(
private \Redis $redis,
private string $keyPrefix = 'fetch:',
private int $defaultTtl = 3600
) {}
}
// Memory cache (for single request lifecycle)
class MemoryCache implements CacheInterface
{
private array $cache = [];
private int $maxItems = 1000;
}
// Multi-tier cache
class TieredCache implements CacheInterface
{
public function __construct(array $caches)
{
// L1: Memory, L2: Redis, L3: File
$this->caches = $caches;
}
}
Cache Key Generation
class CacheKeyGenerator
{
public function generate(RequestInterface $request, array $options = []): string
{
$components = [
'method' => $request->getMethod(),
'uri' => $this->normalizeUri($request->getUri()),
'headers' => $this->extractVaryHeaders($request, $options['vary_headers'] ?? []),
'body_hash' => $this->hashBody($request),
];
return 'fetch:' . md5(serialize($components));
}
private function normalizeUri(UriInterface $uri): string
{
// Remove fragment, normalize query parameters
return $uri->withFragment('')->withQuery($this->normalizeQuery($uri->getQuery()));
}
}
Cache Control Headers
class CacheControl
{
public static function parse(string $cacheControl): array
{
// Parse Cache-Control: max-age=3600, must-revalidate, private
$directives = [];
foreach (explode(',', $cacheControl) as $directive) {
$parts = explode('=', trim($directive), 2);
$directives[trim($parts[0])] = isset($parts[1]) ? trim($parts[1], '"') : true;
}
return $directives;
}
public function shouldCache(ResponseInterface $response): bool
{
$cacheControl = $this->parse($response->getHeaderLine('Cache-Control'));
// Don't cache if explicitly forbidden
if (isset($cacheControl['no-cache']) || isset($cacheControl['no-store'])) {
return false;
}
// Don't cache private responses in shared cache
if (isset($cacheControl['private']) && $this->isSharedCache) {
return false;
}
return true;
}
public function getTtl(ResponseInterface $response): ?int
{
$cacheControl = $this->parse($response->getHeaderLine('Cache-Control'));
// Respect max-age directive
if (isset($cacheControl['max-age'])) {
return (int) $cacheControl['max-age'];
}
// Fall back to Expires header
if ($expires = $response->getHeaderLine('Expires')) {
$expiresTime = strtotime($expires);
return max(0, $expiresTime - time());
}
return null;
}
}
ETags and Conditional Requests
class ConditionalRequests
{
public function addConditionalHeaders(RequestInterface $request, ?CachedResponse $cached): RequestInterface
{
if (!$cached) {
return $request;
}
// Add If-None-Match for ETag
if ($etag = $cached->getETag()) {
$request = $request->withHeader('If-None-Match', $etag);
}
// Add If-Modified-Since for Last-Modified
if ($lastModified = $cached->getLastModified()) {
$request = $request->withHeader('If-Modified-Since', $lastModified);
}
return $request;
}
public function handleNotModified(ResponseInterface $response, CachedResponse $cached): ResponseInterface
{
if ($response->getStatusCode() === 304) {
// Update cache with new headers but keep body from cache
return $cached->withHeaders($response->getHeaders());
}
return $response;
}
}
Stale-While-Revalidate Support
class StaleWhileRevalidate
{
public function handle(
RequestInterface $request,
CachedResponse $staleResponse,
callable $fetchFresh
): ResponseInterface {
// Serve stale immediately
$staleResponse = $staleResponse->withHeader('X-Cache-Status', 'STALE');
// Trigger background revalidation
if ($this->shouldRevalidate($staleResponse)) {
$this->scheduleRevalidation($request, $fetchFresh);
}
return $staleResponse;
}
private function scheduleRevalidation(RequestInterface $request, callable $fetchFresh): void
{
// Use async processing to update cache in background
async(function() use ($request, $fetchFresh) {
try {
$fresh = await($fetchFresh($request));
$this->cache->set($this->generateKey($request), $fresh);
} catch (\Throwable $e) {
$this->logger->warning('Cache revalidation failed', ['error' => $e->getMessage()]);
}
});
}
}
Advanced Features
Intelligent Cache Warming
$client = fetch_client()
->withCache($cache)
->warmCache([
'/api/users' => ['ttl' => 3600, 'priority' => 'high'],
'/api/settings' => ['ttl' => 86400, 'priority' => 'medium'],
'/api/config' => ['ttl' => 604800, 'priority' => 'low'],
]);
Cache Tagging and Invalidation
$response = fetch('/api/users', [
'cache' => [
'tags' => ['users', 'api_v2'],
'ttl' => 3600,
]
]);
// Invalidate by tags
$cache->invalidateByTags(['users']);
Cache Statistics and Monitoring
$stats = $cache->getStats();
// Returns: hit_rate, miss_rate, evictions, size, items, etc.
$client = fetch_client()
->withCache($cache)
->onCacheHit(fn($key, $response) => $metrics->increment('cache.hit'))
->onCacheMiss(fn($key) => $metrics->increment('cache.miss'))
->get('/api/data');
Cache Compression
$cache = new FileCache('/tmp/cache', [
'compression' => 'gzip',
'compression_level' => 6,
'compression_threshold' => 1024, // Only compress responses > 1KB
]);
Use Cases
API Response Caching
// Cache API responses with appropriate TTL
$users = fetch_client()
->withCache(new RedisCache($redis))
->get('/api/users')
->json();
Image/Asset Caching
// Cache images and assets with long TTL
$image = fetch('/images/avatar.jpg', [
'cache' => ['ttl' => 604800] // 1 week
]);
Configuration Caching
// Cache configuration with background refresh
$config = fetch('/api/config', [
'cache' => [
'ttl' => 3600,
'stale_while_revalidate' => 300,
'stale_if_error' => 86400,
]
])->json();
Offline-First Applications
try {
$data = fetch('/api/data', [
'cache' => [
'serve_stale_on_error' => true,
'max_stale' => 86400,
]
])->json();
} catch (NetworkException $e) {
// Will serve stale cache if available
throw $e;
}
Testing Strategy
class CacheTest extends TestCase
{
public function test_caches_get_requests()
{
$cache = new MemoryCache();
$client = fetch_client()->withCache($cache);
// First request - cache miss
$response1 = $client->get('/api/users');
$this->assertEquals('MISS', $response1->getHeaderLine('X-Cache-Status'));
// Second request - cache hit
$response2 = $client->get('/api/users');
$this->assertEquals('HIT', $response2->getHeaderLine('X-Cache-Status'));
$this->assertEquals($response1->getBody(), $response2->getBody());
}
public function test_respects_cache_control_headers()
{
MockServer::fake([
'GET /api/no-cache' => MockResponse::json(['data' => 'test'])
->withHeader('Cache-Control', 'no-cache'),
]);
$cache = new MemoryCache();
$client = fetch_client()->withCache($cache);
$response = $client->get('/api/no-cache');
$this->assertFalse($cache->has($this->generateKey('/api/no-cache')));
}
public function test_etag_conditional_requests()
{
MockServer::fake([
'GET /api/data' => MockResponse::json(['data' => 'original'])
->withHeader('ETag', '"version1"'),
]);
$cache = new MemoryCache();
$client = fetch_client()->withCache($cache);
// First request
$response1 = $client->get('/api/data');
// Mock 304 Not Modified for second request
MockServer::fake([
'GET /api/data' => function ($request) {
if ($request->hasHeader('If-None-Match')) {
return MockResponse::create(304);
}
return MockResponse::json(['data' => 'updated']);
}
]);
$response2 = $client->get('/api/data');
$this->assertEquals(200, $response2->getStatusCode());
$this->assertEquals('original', $response2->json()['data']);
}
}
Benefits
- Performance: 10-100x faster responses for cached content
- Bandwidth Savings: Reduce data transfer by 60-90%
- API Quota Management: Minimize API calls and costs
- Reliability: Serve cached responses during outages
- User Experience: Near-instant response times
- Offline Support: Enable offline-first applications
- Standards Compliance: Full RFC 7234 compliance
- Flexibility: Multiple cache backends and strategies
Migration Path
- Phase 1: Core caching infrastructure and file backend
- Phase 2: Redis backend and conditional requests
- Phase 3: Advanced features (stale-while-revalidate, tagging)
- Phase 4: Performance optimizations and monitoring
Configuration Examples
// Production configuration
fetch_client([
'cache' => [
'backend' => new TieredCache([
new MemoryCache(['max_items' => 100]),
new RedisCache($redis, ['ttl' => 3600]),
new FileCache('/var/cache/fetch', ['ttl' => 86400]),
]),
'respect_cache_headers' => true,
'stale_while_revalidate' => 300,
'compression' => true,
]
]);
// Development configuration
fetch_client([
'cache' => [
'backend' => new MemoryCache(),
'ttl' => 60, // Short TTL for development
'debug' => true,
]
]);
Priority
High Impact, Medium Effort - Caching is fundamental for production applications and significantly improves performance.
Summary
Add RFC 7234 compliant HTTP caching support with intelligent cache management, ETags, Last-Modified headers, and configurable cache backends.
Motivation
HTTP caching is essential for:
Currently, Fetch PHP has no built-in caching mechanism, forcing developers to implement custom solutions or use separate libraries.
Proposed API
Implementation Details
Cache Backends
Cache Key Generation
Cache Control Headers
ETags and Conditional Requests
Stale-While-Revalidate Support
Advanced Features
Intelligent Cache Warming
Cache Tagging and Invalidation
Cache Statistics and Monitoring
Cache Compression
Use Cases
API Response Caching
Image/Asset Caching
Configuration Caching
Offline-First Applications
Testing Strategy
Benefits
Migration Path
Configuration Examples
Priority
High Impact, Medium Effort - Caching is fundamental for production applications and significantly improves performance.