codewiser / http-cache-control
Http Cache Control layer for Laravel
Requires
- php: ^8.0
- laravel/framework: >=10.0
Requires (Dev)
- fakerphp/faker: ^1.21
- orchestra/testbench: ^8.0
- phpunit/phpunit: ^9.6
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
This package provides a solution for working with HTTP Cache-Control headers.
Responses are cached and invalidated on Eloquent model events, so the server can
respond to requests without hitting the database, using cached values only.
Installation
composer require codewiser/http-cache-control
Preparing models
CacheControl uses the cache to store header values. Whenever the underlying
model changes, the associated cache entries must be invalidated.
Models must implement \Codewiser\HttpCacheControl\Contracts\Cacheable.
Here is an example implementation. All classes that share cache tags are invalidated together — changing any model clears the shared cache:
use Codewiser\HttpCacheControl\Contracts\Cacheable; use Codewiser\HttpCacheControl\Observers\InvalidatesCache; use Illuminate\Database\Eloquent\Attributes\ObservedBy; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\Cache; use Psr\SimpleCache\CacheInterface; #[ObservedBy(InvalidatesCache::class)] class User extends Model implements Cacheable { public function cache(): CacheInterface { return Cache::tags(['user', 'order']); } } #[ObservedBy(InvalidatesCache::class)] class Order extends Model implements Cacheable { public function cache(): CacheInterface { return Cache::tags(['order', 'user']); } }
Usage
CacheControl analyzes the incoming request and builds a response with the
proper headers.
Conditional requests
Conditional requests are the core feature of this package. They let clients revalidate a resource they already have instead of downloading it again.
A controller responds with ETag and/or Last-Modified headers. On later
requests, the client sends If-None-Match or If-Modified-Since, making the
request conditional. When nothing has changed, the server answers with
304 Not Modified and an empty body.
This saves bandwidth and, more importantly, database queries: on a 304 the
response callback is never invoked.
What you provide
Enable ETag with etag(). Without arguments, the ETag is computed from the
response content; pass a closure to calculate it yourself:
etag()— implicit, theETagis themd5hash of the response content,etag(fn() => ...)— explicit, the closure must return a string.
Add Last-Modified with lastModified():
lastModified(fn() => ...)— the closure may return an integer timestamp or aDateTimeInterfaceinstance.
Everything else — storing validator values in the cache, comparing them with
incoming conditional headers, and building a 304 / 200 response — is
handled by the package.
How it works
The first argument of CacheControl::make() is the cache to use. It may be:
- a
\Psr\SimpleCache\CacheInterfaceinstance, - a
\Codewiser\HttpCacheControl\Contracts\Cacheableinstance, - the class name of a model that implements
Cacheable.
The second argument is a callback that returns the response content. This callback is invoked only when the response actually has to be generated.
use Codewiser\HttpCacheControl\CacheControl; use Codewiser\HttpCacheControl\CacheControlHeader; public function index(Request $request) { return CacheControl::make( Order::class, fn(Request $request) => OrderResource::collection(Order::all()) ) ->cacheControl(['public' => true]) // Implicit ETag — computed from the response content ->etag() // Or explicit ETag ->etag(fn() => custom_etag_calculation(Order::all())); }
In this example, the server caches only the ETag value — not the body. That
is enough to validate future requests. The client (browser, HTTP cache, CDN)
keeps the full response, so nothing is transferred twice.
use Codewiser\HttpCacheControl\CacheControl; use Codewiser\HttpCacheControl\CacheControlHeader; public function index(Request $request) { return CacheControl::make( Order::class, fn(Request $request) => OrderResource::collection(Order::all()) ) ->cacheControl(['public' => true]) // Return a timestamp or a DateTimeInterface instance ->lastModified(fn() => Order::all()->max('updated_at')); }
The flow of a conditional request
With ->etag() configured, here is what happens on each request:
-
First request. The client asks for
/orderswithout any conditional headers. The response callback runs and generates a200 OKwith the body and anETagheader. The package stores theETagvalue (and nothing else) in the cache. -
The client stores the response and remembers its
ETag. -
Next request. The client asks for
/ordersagain, this time sendingIf-None-Match: "<stored etag>". -
The server validates. The package reads the cached
ETag, compares it with the request'sIf-None-Match, and answers:- Match —
304 Not Modifiedwith an empty body. The response callback is not invoked: no database queries, no serialization. - No match — the callback runs, and a fresh
200 OKwith the body and a newETagis returned. The new validator replaces the cached one.
- Match —
Last-Modified works the same way: the client sends If-Modified-Since with
the date it got last time, and a 304 is returned while the resource is
unchanged.
The package validates twice — once with the cached validator, then again with
a freshly computed one. Even if the cached validator is stale, the client still
gets a 304 whenever its stored value matches the fresh one.
Because only small validator values are stored server-side, this is the most cache-efficient mode. It is also the safest: the check is repeated after regeneration, so a validator is never out of date for more than one request.
Cache-Control header
You can set any Cache-Control directives you like:
use Codewiser\HttpCacheControl\CacheControl; use Codewiser\HttpCacheControl\CacheControlHeader; public function index(Request $request) { return CacheControl::make( Order::class, fn(Request $request) => OrderResource::collection(Order::all()) ) ->cacheControl(fn(Request $request) => new CacheControlHeader( public: true, max_age: 1800, must_revalidate: true, )); }
If neither
publicnorprivateis set, the response defaults toprivate. This also scopes the server-side cache to the authenticated user, so private responses are never shared between users.
Expires header
Alternatively, you can set the Expires header on its own:
use Codewiser\HttpCacheControl\CacheControl; public function index(Request $request) { return CacheControl::make( Order::class, fn(Request $request) => OrderResource::collection(Order::all()) ) ->expires(now()->addHour()); }
Caching the entire response
To cache the entire response content, use remember():
use Codewiser\HttpCacheControl\CacheControl; use Codewiser\HttpCacheControl\CacheControlHeader; public function index(Request $request) { return CacheControl::make( Order::class, fn(Request $request) => OrderResource::collection(Order::all()) ) ->remember() ->cacheControl(new CacheControlHeader( public: true, max_age: now()->addHour(), must_revalidate: true, )); }
When the cached content is still fresh, the response callback is not run at all.
Use with care — the cache may become very large.
You may also provide a closure to compute a per-request cache key:
->remember(fn(Request $request) => 'orders/'.$request->user()?->getAuthIdentifier())
Cache lifetime
By default, cached values do not expire. Use ttl() to control how long
ETag, Last-Modified, and content values are kept in the cache:
->ttl(new DateInterval('PT1H')) // or, in seconds ->ttl(3600)
Private cache
If a controller's response must not be shared across users, set the
Cache-Control: private directive:
use Codewiser\HttpCacheControl\CacheControl; use Codewiser\HttpCacheControl\CacheControlHeader; public function index(Request $request) { return CacheControl::make( Order::class, fn(Request $request) => OrderResource::collection( Order::query()->whereBelongsTo($request->user())->get() ) ) ->cacheControl(new CacheControlHeader( private: true, max_age: new \DateInterval('PT1H'), must_revalidate: true, )); }
The server-side cache key includes the authenticated user's identifier, so each user gets their own cached response.
Vary header
The Vary header lists the request headers that matter for caching. For
example, if your application supports multiple languages, the cache should
depend on the Accept-Language request header:
use Codewiser\HttpCacheControl\CacheControl; use Codewiser\HttpCacheControl\CacheControlHeader; public function index(Request $request) { return CacheControl::make( Order::class, fn(Request $request) => OrderResource::collection(Order::all()) ) ->vary('Accept-Language') ->cacheControl(new CacheControlHeader( public: true, max_age: 1800, must_revalidate: true, )); }
These headers are taken into account when building the cache key, so variants are cached separately.
Note that the web server may append additional
Varyheaders, usuallyAccept-Encoding.