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.
Architecture fit
HandlerStack middleware, not a Laravel framework package. Its Laravel relevance comes from one built-in storage adapter (LaravelCacheStorage) wrapping Illuminate\Cache, which means it can reuse the exact Redis/Memcached/database cache store already provisioned for the application — no new infrastructure, no new credentials, no new operational surface. That is the single strongest argument for adoption from a cost/ownership standpoint.CacheMiddleware → strategy (PrivateCacheStrategy, PublicCacheStrategy, GreedyCacheStrategy, DelegatingCacheStrategy, NullCacheStrategy) → storage (LaravelCacheStorage, Psr6CacheStorage, Psr16, FlysystemStorage, WordPress). The delegating strategy is the important one architecturally: it lets you scope caching per host/route and default to NullCacheStrategy for anything not explicitly opted in. That fail-closed posture is what makes this deployable in a multi-tenant SaaS.Cache-Control/Expires headers, so upstream teams retain control and you avoid hardcoded TTL sprawl.Integration feasibility
$stack->push(...) in the client factory, one service-provider binding. No schema, no migrations, no queue, no daemon. In a Laravel app the friction point is that Illuminate\Http\Client builds its own Guzzle HandlerStack; injecting the middleware requires either passing a fully constructed handler option through Http::globalOptions([...]) or centralizing client construction in a small factory/singleton. Both are a day of work, not a sprint.LaravelCacheStorage and Psr6CacheStorage matter. Flysystem-on-local-disk is a trap in containerized environments — recommend Redis via LaravelCacheStorage and explicitly forbid filesystem storage in the ADR.guzzle_cache module is the only substantive third-party integration cited. Treat this as a single-maintainer library you are effectively adopting, not a de facto standard.Technical risk
sink-option bug. That sink bug is material: before v8, downloads/streams written via a sink could not be honored on cache hits. If any workload writes to disk or streams large payloads, v8 or exclusion from caching is mandatory.Authorization/tenant from the cache key, using GreedyCacheStrategy to override vendor headers on data with regulatory freshness constraints, or serving stale-on-rejection for money-moving values. The v5.1.0 "always return stale on rejection" behavior is a genuine availability win and a genuine correctness hazard depending on endpoint. This must be an allowlist decision, per endpoint, in writing.Key questions to resolve before approval
Cache-Control/Expires/Vary headers? Have we verified Vary handling for negotiated content (Accept-Language, locale, currency), or will we silently serve the wrong variant?GreedyCacheStrategy use, which headers must be folded into the cache key (Authorization, tenant ID, API version, locale)?PublicCacheStrategy ever acceptable given multi-tenancy, or is the whole deployment private-only?Stack fit
LaravelCacheStorage adapter means it inherits whatever Cache::store() provides — TTL enforcement, eviction, tagging where supported, and your existing Redis monitoring. That is the path of least resistance and the path I would approve.ArrayCachePool/in-process storage in production: per-request lifetime makes hit rates near zero under PHP-FPM and hides the cost/benefit from stakeholders.CachingHttpClientFactory (or a scoped Guzzle client bound in a service provider). No controller or job should ever construct a HandlerStack or reference Kevinrob\GuzzleCache\* directly. This is the one architectural non-negotiable: it preserves your options on a fork, a version pin, or a swap to a different caching mechanism.Migration path
Http::get(...) calls onto an injected client. Sequence: introduce the factory, migrate call sites without caching (behavior-preserving), then enable caching per endpoint.Http facade with no caching, there is no data migration — the cache starts cold and warms naturally. No user-visible state change if strategies are correct.Cache::remember() wrapper around HTTP calls: those wrappers must be removed in the same PR that enables the middleware, otherwise you get double caching with divergent TTLs and confusing invalidation. This is the most likely source of "we cached it but it's still stale" incidents.doctrine/cache support — if any legacy code path used it, it moves to PSR-6/PSR-16. v7→v8 requires PHP 8.2, and is compatible with Laravel 13/Symfony 8. Plan v8 as the target; do not adopt v7 now unless blocked by the PHP runtime.Compatibility
__serialize/unserialization regressions, v8 invalidates corrupted entries across all adapters. Always namespace the cache prefix by app version during a major upgrade to avoid reading old-serialized entries.guzzlehttp/retry-subscriber or Laravel's Http::retry(), define and test the intended interaction (recommendation: cache outside retry, so retries of a cache miss are not themselves cached as separate entries).Sequencing
NullCacheStrategy as the default — zero behavior change, full instrumentation.PrivateCacheStrategy on one high-volume, low-risk, read-only GET endpoint. Measure hit ratio, p95 latency, upstream call count for two weeks.DelegatingCacheStrategy keyed by host once you exceed ~3 upstreams.GreedyCacheStrategy with explicit KeyValueHttpHeader for auth/tenant, as a separately reviewed change.NullCacheStrategy) before general rollout.Maintenance
"kevinrob/guzzle-cache-middleware": "~8.0") and treat major upgrades as planned work with a cache-prefix bump. Budget roughly 1–2 engineer-days per major version, more if the cache-key schema changes.Support
Cache-Control.Scaling
LaravelCacheStorage handles high request volume fine, but two things will bite: memory (cached response bodies are unbounded in size unless you cap them) and key cardinality (per-tenant or per-user private caching multiplies keys by tenant count).Cache::lock) around the miss path for those specific endpoints, or stagger TTLs with jitter. This is the most likely scaling incident.Failure modes
| Mode | Symptom | Blast radius | Mitigation |
|---|---|---|---|
| Storage outage (Redis down) | Every request is a miss; latency and vendor spend spike | High, but degrades to no-cache, not to errors — if the adapter fails open | Verify fail-open behavior in staging; alert on hit-ratio collapse |
| Storage failure closed | 5xx on all cached endpoints | Severe | Test explicitly; wrap storage in a try/catch decorator that falls through to network |
| Wrong cache key (missing tenant/auth) | Cross-tenant data exposure | Critical | Never use PublicCacheStrategy; explicit KeyValueHttpHeader for Authorization; adversarial test with two tenants |
| Stale-on-rejection serving obsolete data | Wrong prices/inventory/entitlements shown | Moderate–high | Per-endpoint staleness budget; disable for money-moving reads |
| Corrupted serialized entry | 500s or empty responses | Moderate (fixed in v8) | Stay on v8; version-prefix cache keys per release |
| Vendors stop sending cache headers | Silent hit-rate drop to zero; benefit disappears quietly | Low correctness, high cost | Alert on per-endpoint hit ratio, not just aggregate |
sink/stream mishandling (pre-v8) |
Truncated or empty downloaded files | High for file workloads | v8 minimum, or exclude streaming endpoints |
| TTL/DST arithmetic (pre-v8) | Entries expiring an hour early/late at DST boundaries | Low, hard to diagnose | v8 minimum; regression test with injected PSR-20 clock |
The dominant architectural property to preserve: this middleware must be able to fail without failing the request. Every mitigation above reduces to wrapping storage so that any exception is a cache miss.
Ramp-up
Cache-Control directives, Vary, Age, revalidation vs. freshness, and the difference between private and shared caches. Budget for a short internal primer and a written policy doc, and require that every enabled endpoint names its strategy and key in the ADR. Without that discipline, this package reliably produces "why is this user seeing someone else's data" incidents.How can I help you explore Laravel packages today?