Search by

codewiser / http-cache-control

Cellard

Http Cache Control layer for Laravel

Package info

github.com/C0deWiser/http-cache-control

pkg:composer/codewiser/http-cache-control

Statistics

Installs: 878

Dependents: 1

Suggesters: 1

Stars: 0

Open Issues: 0

v2.1.11 2026-09-05 07:57 UTC

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, the ETag is the md5 hash 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 a DateTimeInterface instance.

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\CacheInterface instance,
  • a \Codewiser\HttpCacheControl\Contracts\Cacheable instance,
  • 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:

  1. First request. The client asks for /orders without any conditional headers. The response callback runs and generates a 200 OK with the body and an ETag header. The package stores the ETag value (and nothing else) in the cache.

  2. The client stores the response and remembers its ETag.

  3. Next request. The client asks for /orders again, this time sending If-None-Match: "<stored etag>".

  4. The server validates. The package reads the cached ETag, compares it with the request's If-None-Match, and answers:

    • Match304 Not Modified with an empty body. The response callback is not invoked: no database queries, no serialization.
    • No match — the callback runs, and a fresh 200 OK with the body and a new ETag is returned. The new validator replaces the cached one.

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 public nor private is set, the response defaults to private. 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 Vary headers, usually Accept-Encoding.