đź“… Updated: February 18, 2026 (Originally published: September 16, 2017)
Understanding PHP HTTP caching is essential for building fast, scalable web applications. PHP’s session handling automatically sends cache-related headers that can interfere with your caching strategy, while frameworks like WordPress add their own layer of complexity. This guide covers everything you need to know about PHP HTTP caching in PHP 8.x and how to properly configure caching for production environments.
How PHP Sessions Affect HTTP Caching
PHP modifies Cache-Control headers automatically when sessions are active. This behavior catches many developers off guard because it happens silently whenever session_start() is called.
When both conditions are true during a request:
session_start()has been calledsession.cache_limiterhas its default value ofnocache
PHP sends these anti-caching headers:
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: no-store, no-cache, must-revalidate
Pragma: no-cache
The Expires date (November 19, 1981) is the birthday of PHP creator Rasmus Lerdorf. This quirky timestamp helps you identify when PHP sessions are responsible for disabling caching.
Important: PHP sends these headers on every request that uses sessions, not just when the session cookie is first set. This means pages that could otherwise be cached by CDNs or reverse proxies like Varnish will be served fresh every time.
PHP 8 Session Cache Limiter Modes
PHP provides five session.cache_limiter modes. Understanding these is crucial for implementing proper PHP HTTP caching:
nocache (Default)
The default mode aggressively prevents all caching:
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: no-store, no-cache, must-revalidate
Pragma: no-cache
Use this for pages with sensitive user data that must never be cached.
private
Allows browser caching but prevents shared cache (CDN/proxy) storage:
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: private, max-age=10800
The max-age value comes from session.cache_expire (default: 180 minutes = 10800 seconds). Use this for user-specific pages that can be cached in the browser.
private_no_expire
Similar to private but without the legacy Expires header:
Cache-Control: private, max-age=10800
This is cleaner for modern applications that rely solely on Cache-Control.
public
Enables caching by browsers and shared caches:
Expires: Wed, 19 Feb 2026 21:42:42 GMT
Cache-Control: public, max-age=10800
Use with caution – only for session-enabled pages where the content is truly public and identical for all users.
none
No caching headers are sent by PHP. This gives you complete control:
<?php
// Disable PHP's automatic cache headers
session_cache_limiter('none');
session_start();
// Now set your own Cache-Control headers
header('Cache-Control: public, max-age=3600, s-maxage=86400');
For most production applications, none is the recommended setting because it lets you implement a custom PHP HTTP caching strategy.
Configuring session.cache_limiter
You can set the cache limiter in three ways:
In php.ini (Global)
session.cache_limiter = none
session.cache_expire = 180
In PHP-FPM Pool Config
php_admin_value[session.cache_limiter] = none
At Runtime (Before session_start)
<?php
// Must be called BEFORE session_start()
session_cache_limiter('none');
session_start();
Warning: Calling session_cache_limiter() after session_start() has no effect and generates a warning in PHP 8.
WordPress Cache Headers
WordPress adds another layer to the PHP HTTP caching puzzle. By default, WordPress does not send Cache-Control headers for regular pages like the homepage or posts.
WordPress only actively disables caching in specific scenarios:
- Error pages (via
wp_die) - Database connection errors
- Comment POST responses
- Logged-in user requests
- 404 pages
- REST API responses with user-specific data
In these cases, WordPress sends:
Expires: Wed, 11 Jan 1984 05:00:00 GMT
Cache-Control: no-cache, must-revalidate, max-age=0
Notice the different Expires date (January 11, 1984). This lets you distinguish between WordPress anti-caching headers (January) and PHP session anti-caching headers (November 1981).
The nocache_headers() function handles this behavior. You can customize it with the nocache_headers filter:
add_filter('nocache_headers', function($headers) {
// Modify anti-caching headers if needed
return $headers;
});
Caching WordPress with Varnish
Since WordPress doesn’t send caching headers for regular pages, Varnish uses its default TTL. The standard approach is:
- Set a high default TTL in Varnish (e.g., 2 weeks)
- Use a cache invalidation plugin like Varnish HTTP Purge
- The plugin purges specific URLs when content changes
This works well but requires VCL configuration to handle the purge requests.
Using s-maxage for Shared Caches
A more elegant approach is having WordPress send Cache-Control headers with s-maxage:
Cache-Control: s-maxage=1209600, max-age=10
This tells:
– Shared caches (Varnish/CDN): Cache for 2 weeks (1,209,600 seconds)
– Browsers: Cache for only 10 seconds
The short browser TTL ensures users see updates quickly after a purge, while the long shared cache TTL maximizes cache hit rates.
You can implement this with a plugin or in your theme’s functions.php:
add_action('send_headers', function() {
if (!is_user_logged_in() && !is_admin()) {
header('Cache-Control: public, s-maxage=1209600, max-age=10');
}
});
Be careful with Jetpack and similar plugins that can add Vary headers affecting cache efficiency.
Last-Modified and ETag Headers
WordPress only implements Last-Modified headers for feeds (RSS/Atom), not for regular pages. The implementation is in wp-includes/class-wp.php.
For pages and posts, you’ll need custom code to send proper Last-Modified and ETag headers:
add_action('template_redirect', function() {
if (is_singular()) {
$post = get_post();
$last_modified = strtotime($post->post_modified_gmt);
header('Last-Modified: ' . gmdate('D, d M Y H:i:s', $last_modified) . ' GMT');
header('ETag: "' . md5($post->ID . $last_modified) . '"');
// Handle conditional requests
if (isset($_SERVER['HTTP_IF_MODIFIED_SINCE'])) {
$client_time = strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE']);
if ($client_time >= $last_modified) {
http_response_code(304);
exit;
}
}
}
});
This enables browsers and proxies to make conditional requests, reducing bandwidth when content hasn’t changed.
Cache-Control Directives Reference
Understanding the full range of Cache-Control directives helps you implement precise PHP HTTP caching:
| Directive | Purpose |
|---|---|
public |
Response can be cached by any cache |
private |
Response is for a single user, no shared caching |
no-cache |
Must revalidate with origin before using cached copy |
no-store |
Never store the response anywhere |
max-age=N |
Response is fresh for N seconds |
s-maxage=N |
Override max-age for shared caches only |
must-revalidate |
Once stale, must revalidate before use |
stale-while-revalidate=N |
Serve stale while revalidating for N seconds |
immutable |
Response will never change, cache indefinitely |
For static assets with fingerprinted filenames, use:
header('Cache-Control: public, max-age=31536000, immutable');
PHP HTTP Caching Best Practices
1. Use session.cache_limiter = none
Take control of your caching headers instead of relying on PHP’s defaults:
; php.ini or PHP-FPM pool config
session.cache_limiter = none
2. Only Start Sessions When Needed
Avoid calling session_start() on pages that don’t need session data. For WordPress, this means being careful with plugins that initialize sessions unnecessarily.
3. Set Appropriate Cache-Control Headers
For public, cacheable pages:
header('Cache-Control: public, max-age=3600');
For user-specific pages that shouldn’t be cached by CDNs:
header('Cache-Control: private, max-age=300');
For truly uncacheable content:
header('Cache-Control: no-store');
4. Use s-maxage for Multi-Layer Caching
When using both browser caching and a reverse proxy:
// Cache 1 day in Varnish/CDN, 5 minutes in browser
header('Cache-Control: public, s-maxage=86400, max-age=300');
5. Implement Cache Invalidation
Headers only control cache duration. For dynamic sites, you need a cache invalidation strategy:
- Varnish: PURGE requests via HTTP
- CDN: API-based purge or cache tags
- Browser: Vary headers or cache-busting query strings
6. Use Vary Headers Correctly
The Vary header tells caches which request headers affect the response:
// Different response for different Accept-Encoding
header('Vary: Accept-Encoding');
// Multiple vary factors
header('Vary: Accept-Encoding, Accept-Language');
Be cautious – excessive Vary headers can fragment your cache and reduce hit rates.
Debugging PHP HTTP Caching Issues
To see what headers PHP is sending:
<?php
session_start();
// Check headers before output
foreach (headers_list() as $header) {
error_log($header);
}
Or use curl to inspect response headers:
curl -I https://example.com/page
Look for the Expires date to identify the source:
– Nov 19, 1981: PHP session cache limiter
– Jan 11, 1984: WordPress nocache_headers()
Browser Developer Tools
Open Network tab, click on a request, and examine Response Headers. Look for:
– Cache-Control – The primary caching directive
– Expires – Legacy expiration header
– Age – How long the response has been in cache (set by CDN/Varnish)
– X-Cache – Whether it was a cache HIT or MISS
Performance Impact
Proper PHP HTTP caching dramatically improves performance:
| Metric | No Caching | With Caching |
|---|---|---|
| TTFB | 200-500ms | 10-50ms |
| Server Load | High | Minimal |
| Scalability | Limited | Excellent |
| Bandwidth | Full response | 304 Not Modified |
Combined with PHP-FPM optimization and Redis object caching, proper HTTP caching can handle traffic spikes that would otherwise overwhelm your server.
Common Mistakes to Avoid
Setting Cache-Control after output starts: PHP cannot modify headers once output has begun. Enable output buffering or structure your code to set headers first.
Caching authenticated content: Never cache pages with user-specific data in shared caches. Always use private or no-store for logged-in users.
Forgetting the Vary header: If your response differs based on request headers (like Accept-Encoding for compression), you must include the appropriate Vary header.
Using only max-age for assets: Static files should include immutable to prevent unnecessary revalidation requests.
Summary
PHP HTTP caching requires understanding three layers:
- PHP session handling – Controls headers via
session.cache_limiter - Application framework – WordPress, Laravel, etc. add their own caching logic
- Infrastructure – Varnish, CDNs, and browser caches interpret the headers
For most production PHP applications:
- Set
session.cache_limiter = noneto take control - Send explicit
Cache-Controlheaders based on content type - Use
s-maxageto differentiate shared cache and browser TTLs - Implement cache invalidation for dynamic content
- Add
Last-ModifiedandETagheaders for conditional requests
Master these concepts and your PHP applications will be faster, more scalable, and more efficient.

