Site icon GetPageSpeed

PHP HTTP Caching: Complete Guide to Cache-Control Headers

PHP HTTP Caching: Complete Guide to Cache-Control Headers

đź“… 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:

  1. session_start() has been called
  2. session.cache_limiter has its default value of nocache

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:

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:

  1. Set a high default TTL in Varnish (e.g., 2 weeks)
  2. Use a cache invalidation plugin like Varnish HTTP Purge
  3. 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:

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:

  1. PHP session handling – Controls headers via session.cache_limiter
  2. Application framework – WordPress, Laravel, etc. add their own caching logic
  3. Infrastructure – Varnish, CDNs, and browser caches interpret the headers

For most production PHP applications:

Master these concepts and your PHP applications will be faster, more scalable, and more efficient.

Exit mobile version