
September 08, 2026
12 min read
By Kokil Thapa | Last reviewed: September 2026
API Caching with ETag and Last-Modified is how you tell clients and proxies whether a response body changed since their last fetch. Without it, every GET hits your database and serialiser even when nothing moved. That wastes CPU on API development projects where read-heavy endpoints dominate traffic. HTTP conditional requests solve this at the protocol layer. Your server sends validators in response headers. The client sends them back on the next request. If the resource is unchanged, you reply with 304 Not Modified and skip the body.
ETag or Last-Modified on responses; clients return If-None-Match or If-Modified-Since. Unchanged resources get 304 with no body.What Is API Caching with ETag and Last-Modified?
Validator-based caching is not the same as storing JSON in Redis. Redis caches computed responses inside your app. ETag and Last-Modified work between the client and your origin through standard HTTP semantics defined in RFC 7232.
An ETag is an opaque token representing a specific version of a resource. It might be a hash of the serialised body, a database row version, or a monotonic revision counter. Last-Modified is a timestamp showing when the resource last changed. Both serve the same goal: let the client ask, “Is my copy still valid?”
On a production Laravel application I maintain, product catalogue endpoints use ETags derived from updated_at plus row count. Mobile clients cache responses locally. Repeat opens send conditional headers instead of pulling 200 KB of JSON again. Server load drops because the controller short-circuits before heavy Eloquent work.
This pattern pairs well with application-level caching covered in Redis caching patterns for web apps. Use Redis for expensive aggregation queries. Use ETags for client-side and CDN revalidation. They complement each other rather than compete.
How Do If-None-Match and If-Modified-Since Work?
Conditional requests rely on request headers the client sends on subsequent GETs. The server compares them against the current resource state and picks a status code.
Response headers your API must emit
ETag: "abc123"— opaque validator, often quotedLast-Modified: Wed, 08 Sep 2026 10:00:00 GMT— HTTP-date in GMTCache-Control: private, max-age=0, must-revalidate— tells intermediaries to revalidate
Request headers the client sends back
If-None-Match: "abc123"— paired with ETag validationIf-Modified-Since: Wed, 08 Sep 2026 10:00:00 GMT— paired with Last-Modified
When validators match, respond with 304 Not Modified. Do not include a body. The client keeps its cached copy. When they differ, respond with 200 OK, the full payload, and fresh validators. This is documented clearly on MDN for the ETag header.
A common mistake is returning 304 with a body. Clients ignore it. Another mistake is omitting validators on paginated list endpoints where each page changes independently.
Example raw HTTP exchange
GET /api/v1/products/42 HTTP/1.1
Host: api.example.com
Accept: application/json
If-None-Match: "prod-42-v7"
HTTP/1.1 304 Not Modified
ETag: "prod-42-v7"
Cache-Control: private, must-revalidate
The round trip still hits your server unless a CDN serves the 304 from edge memory. Even then, you skip serialisation and database reads. That is where the savings appear on read-heavy APIs.
How Do You Implement ETag Caching in Laravel 13?
Laravel 13 on PHP 8.3 or higher gives you middleware and response helpers for conditional requests. The cleanest approach computes a stable ETag once per resource and compares it in middleware or the controller.
Step 1: Generate a deterministic ETag
Hash the serialised output plus a version token. For Eloquent models, combine primary key, updated_at, and a content hash if large text fields exist.
<?php
namespace App\Http\Controllers\Api;
use App\Models\Product;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use App\Http\Controllers\Controller;
class ProductController extends Controller
{
public function show(Request $request, Product $product)
{
$etag = sha1($product->id . '|' . $product->updated_at);
if ($request->header('If-None-Match') === '"' . $etag . '"') {
return response(null, Response::HTTP_NOT_MODIFIED)
->header('ETag', '"' . $etag . '"');
}
$payload = [
'id' => $product->id,
'name' => $product->name,
'price' => $product->price,
];
return response()->json($payload)
->header('ETag', '"' . $etag . '"')
->header('Cache-Control', 'private, max-age=0, must-revalidate')
->header('Last-Modified', $product->updated_at->toRfc7231String());
}
}
For collection endpoints, hash the query fingerprint plus the max updated_at in the result set. If any row changes, the ETag changes. This mirrors patterns from building RESTful APIs with Laravel.
Step 2: Use Laravel's built-in middleware
Laravel ships Illuminate\Http\Middleware\SetCacheHeaders. Apply it on routes that serve cacheable GET responses.
use Illuminate\Http\Middleware\SetCacheHeaders;
Route::get('/products/{product}', [ProductController::class, 'show'])
->middleware(SetCacheHeaders::using([
'private',
'max_age' => 0,
'must_revalidate' => true,
]));
The middleware sets Cache-Control. You still own ETag generation and the 304 branch. Do not assume middleware alone handles conditional logic.
Step 3: Wire middleware for Last-Modified
When your model has a reliable updated_at, compare timestamps instead of hashing.
public function show(Request $request, Article $article)
{
$lastModified = $article->updated_at;
if ($request->headers->has('If-Modified-Since')) {
$since = \Carbon\Carbon::parse(
$request->header('If-Modified-Since')
);
if ($since->greaterThanOrEqualTo($lastModified)) {
return response()->noContent(Response::HTTP_NOT_MODIFIED)
->header('Last-Modified', $lastModified->toRfc7231String());
}
}
return ArticleResource::make($article)
->response()
->header('Last-Modified', $lastModified->toRfc7231String());
}
Truncate timestamps to seconds. HTTP dates have second precision. Microseconds in PHP can cause false mismatches and unnecessary 200 responses.
ETag vs Last-Modified: Which Should Your API Use?
Both headers enable conditional GETs. They differ in precision, cost, and failure modes. Most production APIs I work on use ETag for entity endpoints and Last-Modified as a secondary hint.
| Criteria | ETag | Last-Modified |
|---|---|---|
| Precision | Exact version token; detects any change you encode | Second-level timestamp; misses same-second edits |
| Computation cost | Requires hash or version lookup | Free if updated_at already exists |
| CDN support | Excellent; standard If-None-Match flow | Good; clock skew can cause stale 304s |
| Collections | Strong when you hash query + max updated_at | Weak unless all rows share one timestamp |
| Best fit | High-churn resources, legal documents, product feeds | Static-ish content with reliable timestamps |
Strong ETags change when the representation changes. Weak ETags (W/"...") change when semantically equivalent content shifts. For JSON APIs, strong ETags are simpler. Clients do not need to understand your semantics.
On legal-tech portals where document metadata updates frequently, ETags tied to row version prevent serving outdated PDF metadata. A timestamp alone might miss two edits in the same second during bulk imports.
See also REST API design best practices in 2026 for broader caching and versioning guidance. Pair validators with explicit API versioning so clients know when breaking changes invalidate local stores entirely.
How Should You Configure CDN and Reverse-Proxy Caching?
Validators shine when a CDN or reverse proxy sits in front of your origin. The edge node stores the full 200 response once. Later conditional GETs may be answered at the edge if your Cache-Control allows it.
Cache-Control values that actually work for APIs
private— only the client browser caches; use for authenticated user datapublic, max-age=60, stale-while-revalidate=30— safe for anonymous catalogue readsno-store— disables caching entirely; required for sensitive tokens or PIImust-revalidate— forces revalidation after max-age expires
Never mark personalised JSON as public. A CDN might serve one user's dashboard to another. For shared reference data—currency rates, court fee tables, static lookups—public caching with short max-age works well. Our Nepal forex rates tool pattern applies: data changes on a schedule, not per user.
When using Varnish or similar, configure pass rules for authenticated routes. Let anonymous GETs with validators cache at the edge. Details appear in reverse proxy and caching with Varnish.
Vary header for content negotiation
If your API returns different JSON based on Accept-Language or Accept, add Vary: Accept, Accept-Language. Without it, a CDN might serve English JSON to a Nepali-locale client. Validators must be computed per variant.
return response()->json($payload)
->header('ETag', '"' . $etag . '"')
->header('Vary', 'Accept, Accept-Language')
->header('Cache-Control', 'public, max-age=120, must-revalidate');
Gateway layers from Kong API gateway can strip or normalise headers. Test end-to-end with curl, not only unit tests inside Laravel.
What Mistakes Break API Caching with ETag and Last-Modified?
Validators look simple. Production breaks them in predictable ways. I've hit most of these during deployments on shared EC2 infrastructure.
Unstable ETags from random or time-based hashes
If your ETag includes now() or a random salt, every request looks changed. Clients never get 304. Hash only stable fields: id, updated_at, content checksum.
Ignoring authentication on shared cache keys
Two users requesting /api/me must not share an ETag. Scope validators per user or mark responses private. Review API authentication with Passport vs Sanctum before caching any authenticated route.
Applying validators to non-idempotent methods
ETags apply to GET and HEAD. POST, PUT, PATCH, and DELETE use If-Match for optimistic concurrency, not If-None-Match. Mixing them causes subtle bugs. Idempotency keys solve write retries; see API idempotency keys implementation.
Forgetting to invalidate after writes
When a PUT updates a product, the ETag must change on the next GET. If your write path bypasses Eloquent events or uses raw SQL, updated_at may not move. The client keeps a stale 304 chain until max-age expires.
Testing checklist
Run these curls against staging before release:
# First fetch — capture ETag
curl -i https://api.example.com/api/v1/products/42
# Conditional fetch — expect 304
curl -i -H 'If-None-Match: "abc123"' \
https://api.example.com/api/v1/products/42
# After update — expect 200 with new ETag
curl -i -H 'If-None-Match: "abc123"' \
https://api.example.com/api/v1/products/42
Automate these checks in CI alongside testing and optimization workflows. A regression that drops ETag headers is silent until mobile data bills spike.
For booking APIs like Adventure Third Pole Trek, availability endpoints benefit from short max-age plus ETag. Inventory changes often. Validators prevent stale calendar slots without disabling client cache entirely.
Key Takeaways
- Send
ETagandLast-Modifiedon every cacheable GET; compareIf-None-MatchandIf-Modified-Sincebefore serialising. - Prefer strong ETags built from stable fields; never hash
time()or random values into the token. - Mark authenticated responses
Cache-Control: private; never let CDNs cache per-user JSON as public. - Add
Varywhen language or content negotiation changes the response body. - Combine HTTP validators with Redis for hot queries—see database query caching strategies for the app layer.
- Verify with curl conditionals in CI; a missing header costs more than a failed unit test suggests.
People Also Ask
What is the difference between 304 Not Modified and a cached 200 response?
A 304 tells the client its stored copy is still valid. No body travels over the wire. A cached 200 at a CDN may never reach your origin, but the first request still fetched the full payload. Validators reduce bandwidth on repeat client requests even when no CDN is present.
Should APIs use strong or weak ETags?
Strong ETags are the default choice for JSON APIs. They change whenever the computed representation changes. Weak ETags suit byte-range requests on large files. Most Laravel JSON endpoints should emit strong ETags unless you have a specific reason for weak semantics.
Does ETag caching work with GraphQL?
HTTP caching applies to entire HTTP responses, not individual GraphQL fields. POST-based GraphQL queries rarely benefit from ETag. If you expose GraphQL over GET for read-only operations, validators can work, but REST remains simpler for cache-friendly public data.
How does rate limiting interact with conditional requests?
A 304 still counts as a request at your origin unless the CDN answers it. Price 304s lower than full 200s in your infrastructure budget, but do not assume they are free. Pair with sensible rate limits from rate limiting and API throttling in Laravel.
Ship Faster APIs with Proper HTTP Caching
API Caching with ETag and Last-Modified is the lowest-friction win for read-heavy REST services. You change a few headers and a branch in your controller. Clients, CDNs, and mobile apps do the rest. Start with your top five GET endpoints by traffic. Add deterministic ETags this sprint. Measure 304 rates in your access logs next week.
If you want help auditing an existing Laravel or Symfony API, review Laravel API best practices and broader performance work in improving web performance with caching strategies. For end-to-end delivery—including gateway config, CDN rules, and load testing—explore speed optimization services or contact us to plan a caching pass on your production API.
Frequently Asked Questions
0 Comments
Leave a comment
Your email is not published. Comments appear once they have been read. Sign in to have your details filled in.

