kevinrob/guzzle-cache-middleware
A PSR-7 compatible HTTP cache middleware for Guzzle 6+ with RFC 7234 compliance. Pluggable storage backends include Laravel cache, Flysystem, PSR-6, PSR-16, and WordPress Object Cache. Add it to the HandlerStack to speed up repeated API calls.
Install and smoke-test in four lines. The package is a Guzzle handler-stack middleware, so nothing works until it's attached to a client's HandlerStack. The canonical bootstrap from the README is the whole integration:
composer require kevinrob/guzzle-cache-middleware
$stack = HandlerStack::create();
$stack->push(new CacheMiddleware(), 'cache'); // default = PrivateCacheStrategy + in-memory storage
$client = new Client(['handler' => $stack]);
$client->get('https://api.example.org/items'); // MISS
$client->get('https://api.example.org/items'); // HIT (if the response is cacheable)
The default storage is in-memory, so it dies with the request — treat that first call purely as a wiring check.
Where to look first. Two directories carry all the decisions: src/Strategy/ (what is allowed to be cached, and for how long) and src/Storage/ (where the serialized CacheEntry lives). CacheMiddleware itself is thin. Read PrivateCacheStrategy before anything else — it's the base behaviour every other strategy inherits or deliberately breaks.
Laravel, which is where most of this package's usage lands. Wire it once in a service provider so every Http:: call in the app is cached:
// App\Providers\AppServiceProvider::boot()
$stack = HandlerStack::create();
$stack->push(new CacheMiddleware(
new PrivateCacheStrategy(
new LaravelCacheStorage(Cache::store('redis'))
)
), 'cache');
Http::globalOptions(['handler' => $stack]);
Http::globalOptions() merges into every pending request, and Guzzle picks up the handler option. Use PrivateCacheStrategy here, not PublicCacheStrategy — Laravel apps routinely send Authorization, and the public strategy will refuse to cache those per RFC 9111 (v7+).
First real use case: caching GETs to a third-party API (currency rates, CMS content, geocoding) that already emits Cache-Control/ETag. If the upstream sends no cache headers, jump straight to GreedyCacheStrategy instead.
Pick the strategy from the origin's behaviour, not from your wishes.
| Strategy | Use when |
|---|---|
NullCacheStrategy |
Default/delegate fallback, test environments |
PrivateCacheStrategy |
Authenticated or cookie-scoped responses, Authorization present |
PublicCacheStrategy |
Truly shared data, no auth, safe to serve across users |
GreedyCacheStrategy |
Upstream sends no/weak caching headers; you impose a TTL |
DelegatingCacheStrategy |
One client talks to many hosts with different rules |
Two-tier cache stack. A common production layout is a hot, process-local layer plus a durable layer:
$stack->push(new CacheMiddleware(new PrivateCacheStrategy(new Psr6CacheStorage(new ArrayCachePool()))), 'hot');
$stack->push(new CacheMiddleware(new PublicCacheStrategy(new Psr6CacheStorage($redisPool))), 'shared');
Note the ordering caveat in the next section — push prepends, so the last pushed middleware is the outermost and answers first.
Delegate per host when a single Http client spans several services. Implement RequestMatcherInterface::matches(RequestInterface $request) — one small class per host or per URI prefix — then register them:
$strategy = new DelegatingCacheStrategy(new NullCacheStrategy());
$strategy->registerRequestMatcher(new CmsRequestMatcher(), new PublicCacheStrategy($cmsStorage));
$strategy->registerRequestMatcher(new CrmRequestMatcher(), new PrivateCacheStrategy($crmStorage));
$stack->push(new CacheMiddleware($strategy), 'cache');
Anything unmatched falls through to the default, which should be NullCacheStrategy — fail closed.
Greedy caching for legacy APIs. When the upstream is silent about caching, force a TTL and declare which request headers participate in the cache key:
$stack->push(new CacheMiddleware(
new GreedyCacheStrategy(
new LaravelCacheStorage(Cache::store('redis')),
1800, // seconds
new KeyValueHttpHeader(['Authorization', 'Accept-Language'])
)
), 'greedy-cache');
The KeyValueHttpHeader is not optional in spirit: if responses vary by token or locale and you omit those headers, users receive each other's payloads.
Per-request opt-out. The handler option is merged per request, so you can bypass the cache for a single call without a second client:
Http::withOptions(['handler' => HandlerStack::create()])->get($url);
Keep this escape hatch for "refresh now" buttons and post-write verification reads.
Testing. In phpunit.xml point the app at a NullCacheStrategy handler, or assert caching explicitly against an ArrayCachePool. With v8 you can inject a PSR-20 clock, which lets you assert expiry and Age computation without sleep() — that alone removes most flaky cache tests. Also assert cache hit/miss via the X-Cache response header (see the HEADER_CACHE_* constants) rather than by counting mocked handler invocations.
Custom strategies are the intended extension point: implement CacheStrategyInterface (requestMatches, getCacheEntry, cacheResponse, validateResponse) when you need per-tenant TTLs or a cache-key scheme the built-ins don't express.
push prepends. The last middleware pushed is the outermost and runs first. If you add a private cache and then a shared cache, the shared one answers first and the private layer only sees misses. Dump (string) $stack or call $stack->resolve() when hits are coming from the wrong place.PublicCacheStrategy silently skips Authorization requests (v7+, RFC 9111). If your API calls "mysteriously don't cache", this is why — switch to PrivateCacheStrategy.Vary and custom headers are the classic data-leak vector. Built-in strategies honour Vary, but GreedyCacheStrategy ignores server headers entirely; any header that changes the body must be listed in KeyValueHttpHeader. Combined with a shared Flysystem directory, greedy caching without key headers will serve User A's data to User B.?v=2), by flushing the underlying cache store, or by writing responses under a key you control in a custom strategy. Plan for this before you cache anything user-editable.CacheEntry uses __serialize/__unserialize; older versions had compatibility breaks (v4.1.1/v4.1.2). When bumping major versions, flush the cache namespace — otherwise v8's corrupted-entry invalidation will quietly delete entries on read, which looks like "caching stopped working".sink was only fully honoured in v8. If you stream responses to disk (->get($url, ['sink' => $path])) and rely on cache hits writing the file, upgrading matters — pre-v8 the cache hit could leave the sink untouched.Http::retry() combined with a cached 500 response produces confusing "always errors, never retries" behaviour.stale is served on revalidation failure (since v5.1.0). That's usually desirable, but it means upstream outages can go unnoticed for the length of the freshness window — monitor the X-Cache header values.LaravelCacheStorage writes entries "forever" and lets the entry's own headers govern TTL, so a misconfigured Redis does not silently expire your cache early — but it also means keys never disappear on their own. Cache::store('array') is per-request (per-worker under Octane) and is a test-only tool.Cache::lock() at the application layer.symfony/http-client's cache contracts or a hand-rolled middleware stays a one-file change.How can I help you explore Laravel packages today?