brightfish/caching-guzzle
Caches Guzzle HTTP responses so repeated API calls can be served from your Laravel cache instead of hitting the network again, with configurable TTLs and cache keys for controlling what gets stored and for how long.
Install and confirm what you actually got. This is a 10-star package with zero dependents and a last release in April 2023 — the source is the documentation. Before wiring it into anything, spend five minutes in vendor/brightfish/caching-guzzle/src/ and read the middleware class, the service provider, and the config file. Class names and config keys below are illustrative; match them to what's in src/.
composer require brightfish/caching-guzzle
php artisan vendor:publish --provider="Brightfish\CachingGuzzle\CachingGuzzleServiceProvider"
Check composer show brightfish/caching-guzzle for the Guzzle and Laravel version constraints — a 2023 release may cap Guzzle at ^7.0 and Laravel at ^9|^10, which matters if you're on Laravel 11/12.
First use case: a slow, rate-limited third-party read API. Currency rates, geocoding, a CRM lookup, a carrier tracking endpoint — anything where the same GET is issued repeatedly and the upstream doesn't mind staleness measured in minutes.
use GuzzleHttp\Client;
use GuzzleHttp\HandlerStack;
$stack = HandlerStack::create();
$stack->push(CacheMiddleware::make(ttl: 300), 'cache'); // verify FQCN + signature
$client = new Client([
'handler' => $stack,
'base_uri' => 'https://api.example.com',
]);
Bind it once, not per call site. Register the configured client as a singleton in a service provider so TTL, base URI, and middleware order live in one place:
$this->app->singleton(RatesClient::class, fn () => new RatesClient(
new Client(['handler' => $this->cachedStack(), 'base_uri' => config('services.rates.url')])
));
Verify a hit actually happens. Call the endpoint twice in php artisan tinker and watch the elapsed time, or run redis-cli MONITOR in a second terminal and confirm the second call produces no upstream traffic. If you see two outbound requests, the middleware isn't in the stack — check $client->getConfig('handler') before you debug anything else.
One client per upstream, wrapped in a typed service. Don't scatter new Client() across jobs and controllers. A small RatesClient class that owns its Client gives you one place to change TTL, one place to add auth middleware, and one place to fake in tests.
Middleware ordering matters. Guzzle executes the stack top-down. Push the cache middleware after the middleware that shape the request (auth, base headers), so the cache key reflects the final request — but keep it above the handler so it can short-circuit. If you have a retry middleware, decide deliberately whether retries should re-enter the cache (usually yes — a cached failure shouldn't be served, but a retried request that succeeds should still populate it).
$stack = HandlerStack::create();
$stack->push(Middleware::retry(...), 'retry');
$stack->push($authMiddleware, 'auth');
$stack->push(CacheMiddleware::make(ttl: 600), 'cache'); // closest to handler
TTL tiers, not one global value. Reference data (country lists, category trees) can sit for a day. Volatile data (rates, stock levels) needs minutes. Most packages of this shape let you override the TTL per request; if not, build two clients with different middleware instances. A single global TTL forces you to pick the shortest interval and lose most of the benefit.
Cache key hygiene is the whole job. The key should include: HTTP method, full path, normalized query string (sorted parameters — ?a=1&b=2 and ?b=2&a=1 must collide or you get silent duplication), and for non-GET requests a hash of the body. It should not include anything user-specific unless the response is user-specific. If the package derives keys from the URI alone and you call an endpoint with a per-tenant Authorization header, you will serve tenant A's data to tenant B. Confirm this by reading the key-generation code before you deploy — this is the failure mode that gets you paged.
Invalidation: prefer short TTLs and let Redis expire. Active invalidation is usually a mistake you'll regret. When you genuinely need it — a webhook says "this record changed" — use cache tags if your store supports them, or namespace keys by resource ID so you can purge a family:
Cache::tags(['rates', "rates:{$pair}"])->flush(); // Redis/Memcached only
Bypass explicitly in tests and admin tooling. Provide a ->withoutCache() path or a config flag (config('caching-guzzle.enabled')) so test suites and "refresh now" buttons don't fight the cache. In tests, Cache::flush() in setUp() beats trying to reach inside the middleware.
Wrap it behind your own interface. Given the maintenance profile, treat the package as an implementation detail you could swap for kevinrob/guzzle-cache-middleware or a hand-rolled PSR-16 middleware in an afternoon. A CachedHttpClient interface with one get() method costs nothing and means a rewrite is a swap, not a refactor.
GPL-3.0. This is the loudest signal in the whole assessment. GPL-3 is copyleft: if you distribute the application (on-prem appliance, shipped desktop app, SDK), the license may reach your codebase. Server-side SaaS that you never distribute is generally fine, but get this past whoever owns legal at your company before it's in composer.json on a production branch. Most Laravel packages are MIT; this one isn't.
Zero dependents and a stale release. No one is filing the bug you're about to hit, and no one is patching it. Budget for the possibility that you maintain this yourself. Pin the version exactly and read the diff before any composer update.
Cache stampedes. With no locking, N concurrent requests that miss will all hit the upstream simultaneously — exactly when your rate limit is least forgiving. If the endpoint is expensive, add a short lock around the fetch or accept it and set a staggered TTL.
Caching the wrong things. Confirm the middleware caches only 2xx responses. A cached 429 or 500 with a 10-minute TTL turns a transient upstream blip into a ten-minute outage. Also check behaviour on empty bodies and redirects — some implementations cache a 204 and some don't.
Laravel cache tags are store-dependent. file, database, and array drivers throw on tags(). If your local .env uses CACHE_DRIVER=file and production uses Redis, tag-based invalidation works in prod and explodes on your laptop. Test with the production driver.
Cache-Control from upstream is ignored unless the package respects it. If the source API sends no-store on sensitive responses and this middleware doesn't honour it, you're caching data the provider told you not to. Check, and if it doesn't, add a guard middleware above the cache that strips the cache middleware for those routes.
Query-string ordering and array parameters. ?ids=1,2 and ?ids=1,2,3 are different resources; ?filter[a]=1&filter[b]=2 needs recursive sorting. Sloppy key normalization here produces both false hits (wrong data returned) and false misses (cache never used). Write one unit test per shape against the key generator.
Debugging checklist when it "doesn't cache": (1) is the middleware actually on the stack — dump($client->getConfig('handler')); (2) is the method GET — many implementations only cache GET; (3) is the response status 2xx; (4) does the key differ between calls — log it; (5) is the cache store the one you think — config('cache.default') vs. what the package's config file points at. Those five cover ~95% of "why is this not working."
Testing with Laravel's HTTP client. If you're using Http::fake() elsewhere, note that it fakes Laravel's client, not a raw Guzzle stack. Either keep the fakeable calls on Http:: and the cached calls on the Guzzle client, or inject MockHandler into the stack and let the cache middleware run in front of it — the latter actually tests the caching behaviour, which is the part you care about.
How can I help you explore Laravel packages today?