Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Guzzle Cache Middleware Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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.

Implementation Patterns

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.

Gotchas and Tips

  • 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.
  • No invalidation API. There is no tag-based purge or "forget this URL" method on the middleware. Invalidate by versioning the URL (?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.
  • Upgrade serialization carefully. 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".
  • Bodies are held in memory. A cached 20 MB download occupies memory on every hit and in the storage backend. Don't route file downloads or huge JSON dumps through this middleware.
  • 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.
  • Retries and error responses. Keep the cache middleware at the top so it short-circuits before retry logic, and remember a cacheable 4xx/5xx will be cached and replayed. In Laravel, 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.
  • Storage choices have semantics, not just speed. 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.
  • Concurrency: none of the file/Flysystem storages lock. A cold cache plus parallel workers means a thundering herd of identical upstream calls; wrap the expensive calls in Cache::lock() at the application layer.
  • Version floor: v8 requires PHP 8.2+ and supports Guzzle 8; v7 is the safe target while your Laravel/Flysystem stack is still on Flysystem v3-era dependencies. Pin deliberately — the package moved through Flysystem v2→v3, dropped doctrine/cache, and dropped PHP <8.1 within recent majors.
  • Single-maintainer risk with an otherwise healthy release cadence: keep a thin wrapper (your own client factory or strategy) around it so swapping to symfony/http-client's cache contracts or a hand-rolled middleware stays a one-file change.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
dakiquang/laravel-oauth-refresh-lock
woda/laravel-worktrees
dalefranks/ramasite
faghy/laracashbook
roundly-consulting/google-places-for-laravel
roundly-consulting/kubernetes-api-for-laravel
roundly-consulting/plausible-for-laravel
gvp/gvp-laravel-template
metasyncsite/laravel-metasync-client
roundly-consulting/onboarding-for-laravel
andydefer/laravel-notification
adachsoft/cli-process-tester
jhaskell13/laraflow
sebastienheyd/boilerplate-media-manager
ccast/tagixo-primix
pretendtrue/laravel-builder
sawmainek/laravel-scout-tntsearch-driver
tomshaw/laravel-calendar-table
dibakar/laravel-ownership
adachsoft/changelog-linter