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

Cache Plugin Laravel Package

graham-campbell/cache-plugin

A Laravel package from Graham Campbell that adds a plugin layer to cache stores, letting you attach custom behavior to cache reads, writes, and deletes without changing the underlying driver. Useful for logging, metrics, and other cross-cutting concerns.

View on GitHub
Deep Wiki
Context7

Getting Started

Confirm what you're actually installing first. The package blurb reads like a Laravel cache companion; the code is Graham Campbell's wrapper around php-http/cache-plugin — an HTTP-compliant response cache for PSR-18/HTTPlug clients. Before writing a line of code, open composer.json and src/CachePlugin.php and verify: which upstream it requires, the declared psr/* and PHP constraints, and whether it exposes a CachePlugin class name that maps to the HTTPlug plugin. Budget half a day for this. Everything below assumes it is the HTTP cache plugin.

Composer install:

composer require graham-campbell/cache-plugin php-http/guzzle7-adapter symfony/cache guzzlehttp/guzzle

You need four things in the dependency graph: the plugin itself, a PSR-18 adapter (Guzzle 7 is the common choice), a PSR-6 pool implementation (symfony/cache RedisAdapter or FilesystemAdapter), and a PSR-7/PSR-17 implementation (nyholm/psr7 or guzzlehttp/psr7). The plugin does not pull any of these for you.

Where to look first:

  1. src/CachePlugin.php — this is the whole package. Expect a constructor that mirrors Http\Client\Common\Plugin\CachePlugin (config array + key generator + stream factory).
  2. The upstream docs at https://docs.php-http.org/en/latest/plugins/cache.html — treat these as the real documentation; the wrapper adds almost nothing.
  3. Your PSR-6 pool's serialization behavior (see Gotchas).

First use case — a metered vendor GET in a queue job:

use Http\Client\Common\PluginClient;
use Http\Client\Common\Plugin\CachePlugin as HttpCachePlugin;
use Http\Adapter\Guzzle7\Client as GuzzleClient;
use Symfony\Component\Cache\Adapter\RedisAdapter;
use Nyholm\Psr7\Factory\Psr17Factory;
use GuzzleHttp\Client;

$psr17 = new Psr17Factory();
$pool = new RedisAdapter(
    RedisAdapter::createConnection('redis://localhost'),
    'vendor:geocoding',
    0
);

$httpCache = new HttpCachePlugin($pool, [
    'default_ttl' => 300,
    'respect_response_cache_directives' => ['no-cache', 'private', 'max-age', 's-maxage'],
    'cache_key_generator' => null, // uses RequestUriCacheKeyGenerator
], $psr17, $psr17);

$client = new PluginClient(new GuzzleClient(new Client()), [$httpCache]);

$response = $client->sendRequest($psr17->createRequest('GET', $uri));
$data = json_decode((string) $response->getBody(), true);

Wrap this in a single typed class (GeocodingClient) behind your own interface. The rest of the codebase calls GeocodingClient, never the PluginClient directly.

Implementation Patterns

Wrap the plugin, don't expose it. The plugin surface is small and the failure modes are subtle (body streams, key collisions, header parsing). One bounded class — App\Services\Vendors\GeocodingClient — that owns the pool, the plugin chain, and the PSR-18 client. This is your reversibility guarantee: removing the dependency later is a one-file change, and every caller keeps working.

final class GeocodingClient
{
    public function __construct(private PluginClient $http) {}

    public function lookup(string $address): array
    {
        $req = (new Psr17Factory())
            ->createRequest('GET', 'https://maps.example.com/geocode')
            ->withQueryParams(['q' => $address]);

        $res = $this->http->sendRequest($req);
        if ($res->getStatusCode() >= 400) {
            throw new VendorException((string) $res->getBody());
        }
        return json_decode((string) $res->getBody(), true, 512, JSON_THROW_ON_ERROR);
    }
}

Bind it as a singleton in a service provider so the PluginClient and pool are constructed once per process, not per call. In queue workers, that matters: reconnecting to Redis on every job is measurable overhead.

Key namespacing is your safety lever. The default RequestUriCacheKeyGenerator produces keys from method + URI only. That is fine for public reference data (geocoding, currency rates, country lists) and wrong for anything identity-scoped. For tenant/user/locale-scoped responses, pass a custom CacheKeyGenerator:

class NamespacedKeyGenerator implements CacheKeyGenerator
{
    public function __construct(private string $namespace, private CacheKeyGenerator $inner) {}

    public function generateCacheKey(RequestInterface $request): string
    {
        $identity = $request->getHeaderLine('X-Tenant') ?: 'shared';
        return sha1($this->namespace . '|' . $identity . '|' . $this->inner->generateCacheKey($request));
    }
}

The alternative — and often the better answer — is to send the identity in a header that the plugin hashes into the key, but Vary handling is fragile enough that an explicit generator is the safer default.

Respect upstream cache directives. The plugin's value is not just TTL storage; it parses Cache-Control, Expires, ETag, Last-Modified, and Vary and behaves accordingly. Do not override by passing default_ttl and calling it a day. Configure respect_response_cache_directives to include no-store, private, no-cache, max-age, s-maxage in production — the plugin will then refuse to cache responses a vendor specifically told you not to cache (private often means "user-specific data, don't put it in shared storage"). Getting this wrong is the highest-severity failure mode.

Conditional revalidation is free — use it. For endpoints that return ETag or Last-Modified, the plugin will send If-None-Match / If-Modified-Since and turn a 304 into the cached body. That means you get cheap freshness checks on slow-changing data (config, catalog metadata) without extra code. This is often a bigger cost win than pure TTL caching.

Pilot behind a feature flag. Build the new client next to the old one; keep the old Cache::remember() path for one release. Emit hit/miss counters (see Gotchas) and compare vendor billing + latency before promoting. The plugin caches but does not coalesce concurrent misses — the thundering-herd case on a cold cache is real, so a pilot reveals it before production does.

Laravel Http facade integration path. The facade is Guzzle-native, not PSR-18. Do not try to swap it out globally. Instead, introduce the PluginClient as a parallel client used only by vendor-facing services. If you must bridge, the cleanest route is a PSR-18 adapter (php-http/guzzle7-adapter) constructing a Client from a Guzzle instance, but that instance will not see Http::fake(), Http::preventStrayRequests(), or the facade's middleware. Treat the parallel client as a separate integration point with its own test harness (see Gotchas).

Serialization: keep bodies small and streams rewindable. The pool stores the full PSR-7 response including body stream. symfony/cache serializes via serialize(), which will fail or bloat on non-seekable streams. Ensure the PSR-17 stream factory produces seekable streams (Nyholm does), and cap what you cache: skip responses over ~1 MB by inspecting Content-Length or Content-Type (application/json, text/* yes; application/octet-stream no).

Gotchas and Tips

Test-harness rework is a line item. Swapping a client for PluginClient decouples you from Http::fake(). Mock at the GeocodingClient interface level, or bind a fake PSR-18 client in the container for tests. Budget a half-day per affected test suite or your confidence in unrelated tests erodes quietly.

The pool needs to survive body streams. If you see Error: Serialization of 'GuzzleHttp\Psr7\Stream' is not allowed or mysterious empty bodies on cache hits, the stream is being cached before it is rewound or the adapter cannot serialize it. Two fixes: use Nyholm\Psr7\Stream (fully seekable, serializes to a string) or add a StreamFactory that rewinds on save(). In symfony/cache, check RedisAdapter vs FilesystemAdapter — both serialize, but FilesystemAdapter will hit disk-space issues on large bodies.

private and no-store are not advisory. If a vendor returns Cache-Control: private, max-age=3600 on per-user data, and your pool is Redis-backed, every subsequent request from any user gets the cached body unless you have a per-identity key generator. The plugin honors private if you list it in respect_response_cache_directives; it does not inspect your key strategy for you. Review any endpoint returning private before turning on caching for it.

Vary handling is the second-most-likely surprise. The plugin normalizes Vary headers into the cache key, but only for headers actually present on the request. If a vendor varies on Accept-Language and you do not send it, you get the same response for every locale. Always send locale, tenant, and auth-adjacent headers explicitly on cacheable requests.

Hit/miss observability is not included. Instrument it yourself. The cleanest hook is to wrap the pool's getItem() or to subclass and count:

class InstrumentedPool implements CacheItemPoolInterface
{
    public function __construct(private CacheItemPoolInterface $inner, private Metrics $metrics) {}

    public function getItem($key): CacheItemInterface
    {
        $item = $this->inner->getItem($key);
        $this->metrics->increment($item->isHit() ? 'http_cache.hit' : 'http_cache.miss');
        return $item;
    }
    // ...delegate the rest
}

Push this into your metrics backend alongside a vendor label. Without it, you cannot defend the pilot, cannot spot regressions, and cannot detect key-collision regressions.

Cache-Control: no-cache from upstream means "revalidate", not "don't cache". The plugin will still store the response and send a conditional request on the next call. This is correct per RFC 7234 but surprises people who expect no-cache to behave like no-store. If a vendor uses them interchangeably (many do), add the header to a denylist or set respect_response_cache_directives accordingly.

Kill switch. Provide a config('vendors.geocoding.cache_enabled') flag that swaps the PluginClient for the plain PSR-18 client. Ten lines of code, and the difference between "we rolled back in five minutes" and "we're scrubbing Redis by hand at 2am". Test the switch in staging.

Single-maintainer, zero-dependents risk. The value here is almost entirely in the upstream php-http/cache-plugin, which is mature and widely used. The wrapper itself is thin. If a composer update breaks and the maintainer has moved on, you can require the upstream directly with a one-file change — that is your exit plan. The equivalent behavior is ~50–100 lines if you decide to own it. Document this in the README or an ADR so the next developer does not treat it as load-bearing.

PHP/PSR version pin. Check composer.json constraints against your supported PHP matrix. If the package pins psr/http-client:^1.0 and you are on a Laravel version that ships with a Guzzle adapter still resolving to ^0.3 for some downstream, expect resolver pain. composer why-not is your friend here.

What this package is not. It does not coalesce concurrent misses, does not implement retry/backoff/circuit-breaking, does not warm caches, does not provide tag-based invalidation, does not emit metrics, and does not cache Laravel application responses. If a stakeholder asks for any of those, the answer is "different package" — spatie/laravel-responsecache for page responses, laravel/circuit-breaker or Guzzle middleware for resilience, your metrics stack for observability. Being crisp about the boundary prevents the wrong expectations from attaching to this dependency.

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.
skyraptor/chatter
edlugz/sasapay
abbasudo/artisan-route
mirvan/ippanel
ignibyte/aic-framework
glavweb/data-schema-bundle
glavweb/datagrid-bundle
tacticmedia/scim-server
uni-method/frame
neika-ai/sdk
adachsoft/php-code-style
pharaonic/laravel-notification-dependencies
beautinow/translug
dev-brainiac/bot-cloaked
octoberrain/html
octoberrain/extension
octoberrain/exception
octoberrain/contracts
adachsoft/api-version-bundle
cgv/localized-resource