# PHP edge caching (LSCache)

> Source: https://ferron.sh/docs/use-cases/content/php-edge-cache

Serving PHP through Apache proves reliable. `.htaccess` files, `mod_rewrite`, `mod_php`, and decades of hosting infrastructure all rely on it. Ferron can sit in front of this setup as an edge caching proxy. It adds high-performance HTTP caching, TLS termination, rate limiting, and DDoS protection. You do not need to change your Apache configuration.

```text
Client -> Ferron (edge cache) -> Apache (PHP origin) -> PHP-FPM / mod_php
```

Ferron works with LSCache-compatible PHP plugins such as LiteSpeed Cache for WordPress, Joomla, or OpenCart. Ferron respects `X-LiteSpeed-Cache-Control` headers from the PHP application. This gives you cache control directly from your app. You do not need server-level tuning.

## Basic edge cache configuration

The simplest setup proxies all requests to Apache on `localhost:8080` and enables caching with LSCache override mode:

```ferron
example.com {
    proxy http://127.0.0.1:8080

    cache {
        max_response_size 2097152
        litespeed_override_cache_control
        emit_litespeed_headers
        vary Accept-Encoding
    }
}
```

This configuration:

- Proxies every request to Apache, which handles PHP execution via PHP-FPM or `mod_php`.
- Enables LSCache semantics (`X-LiteSpeed-Cache-Control` from the PHP app controls cache TTL and scope).
- Emits `X-LiteSpeed-Cache` response headers (hit/miss/bypass) for debugging and plugin visibility.
- Caches responses separately by `Accept-Encoding` so the server stores compressed and uncompressed variants independently.

> [!tip]
> Before adding caching, verify that the proxy path works: `ferron validate -c ferron.conf`. Then check that Apache is reachable. Add the caching layer once you confirm the reverse proxy works.

## WordPress with the LSCache plugin

WordPress sites using the [LiteSpeed Cache plugin](https://wordpress.org/plugins/litespeed-cache/) are the most common use of this pattern. The plugin emits `X-LiteSpeed-Cache-Control` headers on pages it considers cacheable. With `litespeed_override_cache_control` enabled, Ferron respects those headers and serves cached copies to later visitors. This reduces Apache and PHP-FPM load.

```ferron
example.com {
    proxy http://127.0.0.1:8080

    cache {
        max_response_size 2097152
        litespeed_override_cache_control
        emit_litespeed_headers
        vary Accept-Encoding
    }
}
```

## Fix for LSCache plugin not detecting a supported web server

The LSCache plugin requires a supported web server (officially, they are LiteSpeed Web ADC, LiteSpeed Web Server or OpenLiteSpeed) to function. If you are using Ferron 3 as a caching reverse proxy for Apache with LSCache-compatible cache, the plugin might not detect that your web server is supported, and as a result, not emit `X-LiteSpeed-Cache-Control` headers, which are required for Ferron to respect them.

To fix this, add this to your `.htaccess` file:

```apache
# This would make Apache masquerade as OpenLiteSpeed,
# so the LSCache plugin will detect it as supported.
SetEnv LSWS_EDITION Openlitespeed
SetEnv X-LSCACHE on
```

## Web hosting control panel support

Ferron 3 + Apache setups can be configured to work with web hosting control panels that support Apache HTTP Server, with some additional configuration to make them work with Ferron 3 (similarly to how it would be done with an additional caching proxy, such as Varnish).

The additional configuration would be responsible for some aspects like TLS termination. The `.htaccess` setup (used by Apache) does not have to be modified in most cases.

> [!tip]
> Ferron provides a demo for integrating HestiaCP (an open source web hosting control panel) with a Ferron 3 + Apache setup (replacing NGINX + Apache in the stack). To learn more, see [the HestiaCP demo repository on GitHub](https://github.com/ferronweb/hestiacp-demo).

## Cache exclusion for admin paths

You must never cache admin areas, login pages, and checkout flows. Use conditional blocks to disable caching for specific paths:

```ferron
example.com {
    proxy http://127.0.0.1:8080

    cache {
        max_response_size 2097152
        litespeed_override_cache_control
        emit_litespeed_headers
    }

    match EXCLUDE_WORDPRESS_CACHE {
        request.uri.path !~ r"^/(?:wp-(?:admin|login\.php)|wc-api)\b"
    }

    if EXCLUDE_WORDPRESS_CACHE {
        cache false
    }
}
```

> [!note]
> The LSCache WordPress plugin already sends `no-cache` directives on admin and login pages by default. The explicit `location` blocks serve as a safety net in case the plugin does not send cache-control headers.

## Caching static files from Apache

Apache still serves static assets (theme CSS/JS, uploaded images, fonts) through the same `mod_php`/`.htaccess` stack as your PHP pages, even though nothing dynamic happens for those requests. Every hit still pays for a full Apache round trip.

You do not need a `location` block or a separate Ferron directive to fix this: the `cache` block from [Basic edge cache configuration](#basic-edge-cache-configuration) already caches these responses. Ferron's cache policy reads the standard `Cache-Control` header on any proxied response.

`litespeed_override_cache_control` only takes effect when the response also carries `X-LiteSpeed-Cache-Control`, and plain static files served directly by Apache (as opposed to pages rendered by the LSCache plugin) never send that header. So standard `Cache-Control` governs static assets, and LSCache semantics keep governing PHP pages, from the same host block.

The only thing missing is telling Apache to send `Cache-Control` on static files. Without it, Ferron would not cache a bare `200 OK` response at all.

Add explicit headers with `mod_headers` and `mod_expires`:

```apache
<IfModule mod_headers.c>
    <FilesMatch "\.(?:css|js|mjs|png|jpe?g|gif|svg|webp|ico|woff2?|ttf|eot)$">
        Header set Cache-Control "public, max-age=2592000, immutable"
    </FilesMatch>
</IfModule>
```

Use `immutable` only for assets with a hashed or versioned filename (for example `app.a1b2c3.js`), since it tells clients and Ferron never to revalidate the entry for the lifetime of `max-age`.

For unversioned assets that change in place, drop `immutable` and pick a shorter `max-age`, or plan to `PURGE` the entry after a deploy (see [PURGE method cache invalidation](https://ferron.sh/docs/configuration/content/cache.md#purge-method-cache-invalidation)).

With that header in place, the flow looks like this:

1. The first request for `/theme/style.css` reaches Ferron, which has no cached entry and proxies to Apache.
2. Apache serves the file with `Cache-Control: public, max-age=2592000, immutable`.
3. Ferron stores the response body and headers, keyed by the request path (and by `Accept-Encoding`, thanks to `vary Accept-Encoding` in the `cache` block).
4. Every later request for that asset, from any client, is served directly from Ferron's in-memory cache. Apache never sees it again until the entry expires or is purged.

> [!tip]
> Static assets are usually far larger than typical PHP HTML responses. If images or bundled JS exceed the default `max_response_size` (2 MB), Ferron proxies them correctly but does not cache them. Raise `max_response_size` in the `cache` block, or give static assets their own [named zone](https://ferron.sh/docs/configuration/content/cache.md#cache-zones) so a few large files do not crowd out cached HTML pages.

> [!note]
> This pattern only removes the Apache round trip for GET/HEAD requests that qualify for caching. Requests with `Authorization` headers, or responses that carry `Set-Cookie`, still bypass the cache unless the response also authorizes shared caching (`public` or `s-maxage`). See [Public and private cache behavior](https://ferron.sh/docs/configuration/content/cache.md#public-and-private-cache-behavior).

## Cache purging from PHP

When content changes (a new blog post, updated product, or comment), you must invalidate the cache. LSCache plugins emit an `X-LiteSpeed-Purge` response header to invalidate tagged or URL-specific cache entries. Ferron processes these headers automatically when you enable caching. You do not need additional configuration.

## Forwarding client IP to Apache

When Ferron terminates client connections, Apache sees all traffic as coming from Ferron's IP address. Ferron forwards the original client IP automatically so PHP applications and Apache logs see real visitor addresses.

On the Apache side, enable `mod_remoteip` to trust Ferron's IP and use `X-Forwarded-For` for logging and access control:

```apache
# Enable in Apache config or an included snippet
RemoteIPHeader X-Forwarded-For
RemoteIPTrustedProxy 127.0.0.1
```

Replace `127.0.0.1` with Ferron's actual IP if running on a different host.

> [!tip]
> Without `mod_remoteip` (or equivalent), Apache logs and PHP applications show Ferron's IP address for every request. WordPress plugins that depend on visitor IP (geolocation, security, analytics) will not work correctly.

## Additional edge features

Because Ferron terminates client connections before they reach Apache, you can add edge-level features that apply to all traffic. These features apply to cached responses that never touch the backend:

- **TLS termination.** Offload HTTPS at Ferron with automatic (ACME) or manual certificates. See [Automatic TLS](https://ferron.sh/docs/use-cases/https/automatic-tls.md) and [Manual TLS](https://ferron.sh/docs/use-cases/https/manual-tls.md).
- **Rate limiting.** Protect Apache and PHP from traffic spikes and brute-force attacks. See [Rate limiting](https://ferron.sh/docs/use-cases/security/rate-limiting.md).
- **Abuse protection.** Drop malicious requests before they reach the backend. See [Abuse protection](https://ferron.sh/docs/use-cases/security/abuse-protection.md).
- **Security headers.** Add headers like `Strict-Transport-Security`, `Content-Security-Policy`, and `X-Frame-Options` at the edge. See [Security headers](https://ferron.sh/docs/use-cases/security/security-headers.md).
- **Observability.** Log requests, monitor cache hits/misses, and track performance metrics. See [Logging & observability](https://ferron.sh/docs/use-cases/operations/logging-observability.md).

> [!note]
> Ferron serves cached responses directly from its in-memory cache. Cached responses never reach Apache. This means edge-level features like rate limiting and security headers still apply. Backend-side logic (`.htaccess` rewrites, Apache access controls) does not run on cache hits.

## See also

- [HTTP caching](https://ferron.sh/docs/use-cases/content/caching.md): general caching patterns and LSCache overview
- [PHP hosting](https://ferron.sh/docs/use-cases/content/php.md): running PHP directly on Ferron without Apache
- [Reverse proxying](https://ferron.sh/docs/use-cases/traffic/reverse-proxy.md): proxy configuration reference
- [Configuration: HTTP cache](https://ferron.sh/docs/configuration/content/cache.md): full cache directive reference