micheh/psr7-cache
PSR-7 HTTP caching middleware for PHP that stores and reuses responses according to RFC 7234, handles cache headers and conditional requests, and integrates with PSR-7/PSR-15 stacks to reduce backend load.
This release (0.5) is additive — no breaking changes — so upgrading is a composer update away. Pull it in and re-run your test suite; the only behavioural difference existing code can notice is that If-None-Match now also matches weak ETags (W/"abc"), which is a correctness fix rather than a regression.
Where to look first:
$util in the release notes) that you inject into a controller, resolve from the container, or wrap in your own ConditionalRequests helper. Everything in this package flows through that one class, so read its public methods end to end (there are only a handful: state validator detection, current-state checks, and the comparison internals).hasStateValidator($request) and hasCurrentState($request, $eTag, $lastModified). The first tells you whether the incoming request carries If-Match / If-Unmodified-Since (i.e. the client is making an unsafe conditional request); the second is the actual precondition evaluation.ETag to a GET response and short-circuit to 304 when hasCurrentState() is true.First use case — put it on a "show" endpoint:
public function show(Request $request, Post $post, ConditionalRequest $util)
{
$eTag = hash('xxh128', $post->updated_at.$post->id);
if ($util->hasCurrentState($request, $eTag, $post->updated_at)) {
return response()->noContent(304)->setEtag($eTag);
}
return response()
->json($post)
->setEtag($eTag)
->setLastModified($post->updated_at);
}
Then add the unsafe half — the reason 0.5 exists — to your write endpoints:
public function update(Request $request, Post $post, ConditionalRequest $util)
{
if ($util->hasStateValidator($request) && ! $util->hasCurrentState($request, $eTag, $post->updated_at)) {
abort(412, 'Precondition Failed'); // client is editing a stale copy
}
// ... apply the update
}
Centralise the validators, don't scatter them. Compute the ETag in one place — an accessor, a HasETag trait, or a small ResourceValidator class — and reuse it for both the response header and the precondition check. Mismatched ETag generation between read and write paths is the number one source of "412 on everything" bugs.
Middleware over per-action boilerplate. For read-heavy APIs, a RevalidateCache middleware that computes the ETag from the response and converts to 304 is cleaner than repeating the check in every controller:
return $next($request)->tap(function ($response) use ($request, $util) {
if ($response->isSuccessful() && $request->isMethodSafe() && $util->hasCurrentState($request, $response->getEtag(), $response->getLastModified())) {
// strip body, set 304
}
});
Register it globally for GET/HEAD only — never for unsafe methods.
Optimistic concurrency for writes. The idiomatic flow for PUT/PATCH/DELETE:
| Situation | Response |
|---|---|
No If-Match/If-Unmodified-Since sent |
Your choice: process normally, or 428 Precondition Required if you want to force clients to be careful |
| Validator sent and matches current state | Process the write, return the new ETag |
| Validator sent and does not match | 412 Precondition Failed with the current representation so the client can merge |
Always return the fresh ETag after a successful write so the client can chain requests without a re-read.
Combine with a row lock, not instead of one. If-Match is optimistic concurrency at the HTTP layer; it does not stop two requests with the same valid ETag racing. Wrap the check and the write in DB::transaction() with lockForUpdate() (or rely on a version column updated with a where version = ? clause) if the resource is contended.
Prefer ETags for writes, Last-Modified as a fallback. HTTP dates have one-second resolution, so two updates within the same second are indistinguishable to If-Unmodified-Since. Emit Last-Modified for compatibility and cache friendliness, but make the ETag the authoritative validator (derive it from updated_at + a version/counter column, not just updated_at).
Use weak semantics where equivalence ≠ byte-identity. With 0.5's weak comparison, W/"abc" correctly satisfies If-None-Match: "abc" and vice versa. That's what you want for GET where the representation is semantically identical (e.g. JSON key ordering, whitespace, gzip differences). Keep strong ETags on anything you'll later guard with If-Match.
Testing. Laravel's test helpers make this trivial — assert the headers on the first response and replay them:
$first = $this->getJson("/api/posts/{$post->id}")->assertOk();
$this->withHeaders(['If-None-Match' => $first->headers->get('ETag')])
->getJson("/api/posts/{$post->id}")
->assertStatus(304);
$this->withHeaders(['If-Match' => '"bogus"'])
->patchJson("/api/posts/{$post->id}", ['title' => 'x'])
->assertStatus(412);
Add a weak-variant test (W/ + the same opaque tag) — that path is new in 0.5 and worth pinning down so a future release doesn't silently change it.
Document the contract. Clients need to know they should store the ETag from a GET and send it as If-Match on the subsequent write. Add it to your OpenAPI spec (ETag response header, If-Match/If-None-Match parameters, 304/412/428 responses) so generated clients handle it.
304 an unsafe method. A failed If-None-Match on PUT/PATCH/DELETE is 412 Precondition Failed, not 304. Only GET/HEAD get the not-modified shortcut. hasCurrentState() intentionally does the comparison only — the status code choice is yours, and it's easy to get wrong in a shared middleware.If-Match must use strong comparison (RFC 9110). A weak W/"..." validator must never satisfy If-Match. 0.5 added weak comparison for If-None-Match specifically; verify with a test that your If-Match path still rejects weak tags, and avoid emitting W/ ETags on resources you intend to guard with If-Match.If-Match: *. Per spec, * means "the resource must exist" and matches any current representation. If the util doesn't special-case it, you'll return spurious 412s to well-behaved clients. Test it.If-Unmodified-Since cannot distinguish two writes in the same second — classic lost-update bug on fast endpoints. Always pair with an ETag or a monotonically incrementing version column.auth/authorize before the precondition check, otherwise a 412 vs 403 difference leaks whether a resource exists (and lets an unauthorised caller learn its ETag). Put the precondition check before any write-side side effects (queued jobs, events) so a failed precondition costs nothing.Last-Modified must be GMT. Laravel will format a Carbon instance, but if you hand it a string or a local-time DateTime you can end up with a header the client parses wrong — always ->setLastModified($model->updated_at->utc()) or an immutable_datetime cast."abc", W/"abc") — Response::setEtag($value) handles quoting, but if you build the header manually or compare raw header strings you'll get mismatches. Use the package's comparison rather than === on header values.Cache-Control alongside your validators. An ETag with Cache-Control: public, max-age=3600 means clients won't revalidate at all. Use no-cache (store but always revalidate) or private, must-revalidate for per-user data. Add Vary on Authorization/Accept-Encoding/Accept where relevant, or a CDN will happily serve one user's 304 decision to another.noContent(304) in tests. A 304 has no body — assertJson/assertSee will fail and assertNoContent() may be too loose. Assert the status and headers explicitly; also assert that your normal 200 body is unaffected.If-Range implementation, or If-None-Match: * on POST), subclass or decorate the util and bind it in a service provider — that keeps you on the upgrade path for the next release's comparison fixes, which is exactly what 0.5 was.If-None-Match and for If-Match/If-Unmodified-Since. (3) Confirm your write endpoints now return 412 rather than silently applying stale updates — some clients may surface this as a new error, so coordinate before deploying.How can I help you explore Laravel packages today?