guzzlehttp/cache-subscriber
A Guzzle subscriber that adds caching to HTTP requests, letting you store and reuse responses to reduce redundant network calls. Now deprecated in favor of Guzzle's built-in cache middleware, it remains useful for older projects needing simple response caching.
Reality check before you install. guzzlehttp/cache-subscriber is deprecated and archived, last tagged in September 2019. It targets Guzzle 5 and 6. Laravel 9+ ships Guzzle 7, so the first thing you should do is not run composer require, but check whether it can even resolve:
composer why-not guzzlehttp/guzzle 7
composer require guzzlehttp/cache-subscriber --dry-run
If it pins you to Guzzle 6, stop and read the Gotchas section — you're trading a maintained HTTP client for a five-year-old cache layer. The recommended read-only exercise below is how you'd evaluate it on a legacy Laravel 8 app that is already stuck on Guzzle 6.
Where to look first (in this order):
README.md — the single CacheSubscriber::attach($client, [...]) example is the whole public API.src/CacheSubscriber.php — the attach/options surface (storage, default_ttl, respect_cache_control, allow_revalidate).src/CacheStorageInterface.php — the four or five methods you must implement to back the cache with Laravel. This is the file that matters most.src/CacheStorage.php — the default in-memory store. Reading it tells you exactly what payload shape gets cached (a response object plus its body string), which drives your serialization choices.tests/ — option names and header-handling behaviour are only fully documented here.First real use case: a slow, anonymous, read-heavy third-party GET — FX rates, VAT validation, postcode lookup, a public catalogue endpoint — where a 5-minute staleness window is acceptable and a 300 ms round trip is not.
use GuzzleHttp\Client;
use GuzzleHttp\Subscriber\Cache\CacheSubscriber;
$client = new Client(['base_uri' => 'https://api.rates.example.com/']);
CacheSubscriber::attach($client, [
'default_ttl' => 300,
'respect_cache_control' => true,
]);
$response = $client->get('v1/latest?base=EUR');
That snippet caches nothing across requests — the default storage is in-process memory and dies with the PHP worker. Supplying a storage implementation backed by Illuminate\Cache is the first hour of real work, and it is the deciding factor in whether this package is usable for you at all.
1. Build a storage adapter over Illuminate\Cache\Repository. This is the only integration point worth writing; everything else is configuration.
namespace App\Http\Clients\Cache;
use GuzzleHttp\Message\RequestInterface;
use GuzzleHttp\Message\ResponseInterface;
use GuzzleHttp\Subscriber\Cache\CacheStorageInterface;
use Illuminate\Contracts\Cache\Repository;
final class LaravelCacheStorage implements CacheStorageInterface
{
public function __construct(
private Repository $cache,
private string $prefix = 'guzzle:',
private int $maxKeyLength = 200,
) {}
public function get($key)
{
return $this->cache->get($this->normalize($key));
}
public function set($key, array $data, $ttl = 0)
{
$ttl > 0
? $this->cache->put($this->normalize($key), $data, $ttl)
: $this->cache->forever($this->normalize($key), $data);
}
public function delete($key)
{
$this->cache->forget($this->normalize($key));
}
public function purge()
{
// Illuminate's cache has no key index; use tags or a versioned prefix.
}
public function createKey(RequestInterface $request, ResponseInterface $response = null)
{
return $this->prefix . sha1($request->getUrl() . '|' . json_encode($request->getHeaders()));
}
private function normalize(string $key): string
{
return strlen($key) > $this->maxKeyLength ? $this->prefix . sha1($key) : $key;
}
}
Register one cached client as a singleton in a service provider, never instantiate it in controllers:
$this->app->singleton('rates.client', function ($app) {
$client = new Client(['base_uri' => config('services.rates.url')]);
CacheSubscriber::attach($client, [
'storage' => new LaravelCacheStorage($app['cache']->store('redis')),
'default_ttl' => 300,
'respect_cache_control' => true,
]);
return $client;
});
2. Split clients by caching policy, not by endpoint. Two clients is the sweet spot: a cached, anonymous client for reference data, and an uncached client for anything carrying Authorization, cookies, or user identifiers. The package has no concept of your tenancy; a URL-keyed shared cache will serve User A's data to User B.
3. Decide the respect_cache_control question explicitly. Upstreams frequently send Cache-Control: no-store, private on endpoints you do want to cache locally. Forcing respect_cache_control => false and a fixed default_ttl is legitimate, but it is a deliberate override of the provider's wishes — put it in a comment and check the vendor's ToS. Turning allow_revalidate on stores ETag/Last-Modified and issues conditional requests: good for multi-MB payloads, wrong for latency-critical paths where a 304 round trip costs as much as the original call.
4. Warm the cache out of band. User-facing requests should never pay the first-call penalty on a cold Redis. Add a console command that iterates the small, known set of hot keys and a Job per key, running on a schedule. This also gives you something the package never provides: visibility into what should be in the cache.
5. Decorate the storage for observability and tenancy. A LoggingCacheStorage or MetricsCacheStorage that wraps the real adapter and records hits/misses is twenty lines and gives you everything the package lacks — hit rate, top keys, staleness. The same wrapper is where you namespace keys by tenant or user (prefix = "guzzle:{$tenantId}:").
6. Test against a fake storage, not against the network. Pair Guzzle's MockHandler with a recording CacheStorageInterface implementation and assert that the second get() never reaches the handler. That assertion is the entire value proposition of the package in one test, and it will also catch the serialization failures described below.
7. Hide it behind a thin client class. final class CachedHttpClient { public function get(string $uri): array; } wrapping the configured Guzzle client means migrating to kevinrob/guzzle-cache-middleware or a custom HandlerStack middleware later is a one-file change, not a grep across the app.
CacheSubscriber::attach($client) with no storage uses an in-memory array that is discarded when the request ends or the queue worker recycles. Every snippet online that omits storage caches nothing. Budget the adapter work before you promise anyone a performance win.kevinrob/guzzle-cache-middleware (Guzzle 6/7, PSR-6 CacheItemPoolInterface) or php-http/cache-plugin instead — both point at a PSR-6 pool, which you can back with symfony/cache's adapters or a small wrapper around Illuminate\Cache. Same cache, same patterns above, no abandoned dependency.Vary headers only. A cached client that ever sends Authorization will return one user's payload to another. Never attach it to authenticated clients, or namespace the key with the user/tenant id in createKey.429 Too Many Requests or 503, the two cases where you most want a cache. Handle rate-limit backoff yourself.serialize()/Redis round trips reliably (truncated bodies, or exceptions on a consumed stream). Store the body as a string and rebuild the response on read, and test against Redis, not the array store — array cache happily "passes" because nothing is serialized.purge() cannot be implemented honestly. There's no key index. If you need "refresh this record now", keep your own registry of keys (a cache-backed set) or use a tagged store and forget by tag. Do not assume purge() works.sha1) regardless of which backend you pick — it costs nothing and prevents a silent fallback to "never caches".Vary and gzip fragmentation. If upstream varies on Accept-Language and your CDN or proxy strips it, you'll serve wrong-language payloads. Similarly, inconsistent Accept-Encoding fragments the cache into near-duplicate entries. Pin the headers you send per client and keep them consistent.storage actually configured and reachable? dump keys directly from the store; (2) does the request carry Cache-Control: no-cache or max-age=0 from a previous middleware? (3) does the response carry no-store/private while respect_cache_control is on? (4) is the second request even using the same Client instance — a fresh new Client() per call is fine for Guzzle, but a fresh storage instance with an array store is not. Add \GuzzleHttp\Middleware::log before the cache middleware so you can see which calls actually leave the process.CacheStorageInterface (decorate it for metrics, logging, key namespacing, tenancy) and the option array passed to attach(). There is no hook for "should this response be cached" — if you need conditional caching rules, you're writing your own HandlerStack middleware, at which point you should just use the maintained alternative.guzzle-cache-middleware. On any new Laravel project it is a straight "no" — write the LaravelCacheStorage equivalent against a maintained middleware and you get the same behaviour with an upgrade path.How can I help you explore Laravel packages today?