Skip to content

PageCacheConfig

Configuration for the static page cache.

This object is the feature's single source of truth. Four readers serve the cache — the advanced-cache.php drop-in, a generated nginx snippet, a generated .htaccess block, and the writer inside WordPress — and three of them cannot ask WordPress anything. They all derive from this object instead: the drop-in requires the config file, and wp foehn cache:config renders the server snippets from it. Three hand-written copies of "is this request cacheable" that drift apart is the defect the design exists to avoid.

Signature

php
<?php

namespace Studiometa\Foehn\Config;

final readonly class PageCacheConfig
{
    public function __construct(
        public bool $enabled = false,
        public ?string $path = null,
        public int $ttl = 0,
        public int $browserMaxAge = 0,
        public array $environments = ['production'],
        public array $bypassCookies = ['wordpress_logged_in_', 'comment_author_', 'wp-postpass_'],
        public array $ignoredQueryArgs = [
            'utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'utm_id',
            'gclid', 'fbclid', 'msclkid', 'mc_cid', 'mc_eid', '_ga', 'ref',
        ],
        public array $cacheQueryArgs = [],
        public array $excludedPaths = [],
        public array $excludeWhenBodyContains = [],
        public bool $cacheNotFound = false,
        public ?bool $debugHeaders = null,
    );
}

Properties

PropertyTypeDefaultDescription
enabledboolfalseMaster switch. Nothing is written or served while this is false.
pathstring|nullnullCache root. Defaults to WP_CONTENT_DIR . '/cache/foehn/pages'.
ttlint0Seconds a stored page stays servable. 0 = until something purges it.
browserMaxAgeint0max-age sent to the browser for cached HTML.
environmentslist<string>['production']Environments where caching is allowed at all.
bypassCookieslist<string>three WordPress prefixesA request carrying one of these cookie prefixes is never served or written.
ignoredQueryArgslist<string>thirteen tracking argsStripped before the filename is computed, so tracking links still hit.
cacheQueryArgsarray<string, string>|list<string>[]Args that go into the filename, each with the pattern its value must match.
excludedPathslist<string>[]URL path prefixes never cached.
excludeWhenBodyContainslist<string>[]Response bodies containing one of these substrings are not stored.
cacheNotFoundboolfalseStore 404s as well as 200s.
debugHeadersbool|nullnullEmit the X-Foehn-Cache headers. null follows WP_DEBUG.

Usage

php
<?php
// app/page-cache.config.php

use Studiometa\Foehn\Config\PageCacheConfig;

return new PageCacheConfig(
    enabled: true,
    ttl: 8 * HOUR_IN_SECONDS,
);

Environment-suffixed files work as they do for every other config object, and the environment's own file wins over the plain one beside it — which is how a project enables the cache in production without enabling it on a developer's machine:

php
<?php
// app/page-cache.production.config.php

return new PageCacheConfig(enabled: true, ttl: 8 * HOUR_IN_SECONDS);

Notes on individual options

enabled and environments

Both guards apply. enabled: true with the default environments does nothing outside production, which wp foehn cache:status says out loud rather than leaving you to guess:

Enabled: Yes
Environment: local (allowed: production)
...
Warning: The page cache is enabled but inert in the 'local' environment.

path

A path outside the document root is a legitimate choice, and the drop-in serves it happily — but no web server can reach a file by name from outside its own root, so cache:config refuses to generate a snippet and says why rather than emitting a broken one.

ttl

Enforced exactly by the drop-in, and by an hourly #[AsCron] sweep on the nginx and Apache paths, because neither try_files nor mod_rewrite can look at a file's age. With a ttl set, the sweep interval is the real bound on staleness.

browserMaxAge

0 plus must-revalidate means a purge takes effect immediately, because the browser asks every time and gets a 304 when nothing changed. Raising it trades that away: a visitor holds the old page for as long as you allow, and no server-side purge can reach them.

bypassCookies

Prefixes, matched against cookie names. The three defaults are how WordPress marks a visitor as not-anonymous: logged in, a returning commenter, and someone who has entered a post password. Add your own for a plugin that personalises a page from a cookie.

ignoredQueryArgs

Arg names, matched exactly — utm_source does not match utm_sourcex. Order does not matter: ?utm_source=a&utm_medium=b and ?utm_medium=b&utm_source=a read the same file, in all three readers.

Only add an arg here when it genuinely does not change the page. An arg that does change it belongs in cacheQueryArgs instead — where it is kept rather than dropped. A name in both lists is a contradiction, and the keyed meaning wins.

cacheQueryArgs

Args that change which page is being asked for, so they belong in the key rather than in the bin:

php
cacheQueryArgs: ['page' => '^[0-9]{1,6}$', 'lang' => '^[a-z]{2}$'],
cacheQueryArgs: ['page', 'lang'],   // shorthand: ^[A-Za-z0-9_.\-]{1,64}$

?page=2&lang=fr and ?lang=fr&page=2 both store as index__lang=fr&page=2&.html. The order in the filename is this array's order sorted by name, not the request's: every reader walks the list and asks for each name's value, which is how two spellings of one URL reach one file without nginx — which cannot sort — having to.

Each value is checked against its pattern and against the characters a filename may hold; your pattern can narrow that floor, never widen it. A present-but-invalid value bypasses to PHP rather than falling back to the unkeyed page. ?page= counts as absent, and a repeated name bypasses, because nginx keeps the first occurrence and PHP the last.

An entry this cache cannot honour — a name with a space in it, a pattern that does not compile — is dropped, which leaves it an argument nobody configured, which is a bypass.

Keep the list short. Each name is one the generated snippets unroll into six nginx statements, and WordPress paginates on /page/2/ paths rather than ?page=2, so this is for filtered archives, lang, print views. Re-run wp foehn cache:config --write after changing it, and note that Apache cannot serve keyed args at all — those requests fall through to the drop-in.

excludedPaths

Prefixes, so '/contact/' also excludes /contact/thanks/. The place to put anything that has to be rendered fresh: a nonce-bearing form, a cart, an account page.

excludeWhenBodyContains

Substrings of the rendered HTML, checked before the page is stored. Catches what excludedPaths cannot, because it does not need to know which URLs a plugin puts a form on:

php
excludeWhenBodyContains: ['name="_wpnonce"'],

debugHeaders

Left at null this follows WP_DEBUG, which is the right default: the headers are how the feature is debugged, and there is nothing secret in them. Set it to true in production while you are diagnosing something.

Methods

MethodReturnsDescription
getPath()stringThe resolved cache root.
wantsDebugHeaders()boolWhether the X-Foehn-Cache headers are emitted.
allowsEnvironment(?string $environment)boolWhether caching is allowed in an environment. Defaults to the current one.
PageCacheConfig::environment()stringThe environment, resolved without needing WordPress loaded.

environment() is static and deliberately does not depend on WordPress being booted: the drop-in has to answer the same question before wp-settings.php has finished. It prefers wp_get_environment_type(), falls back to the WP_ENVIRONMENT_TYPE constant, then the environment variable, then production — as WordPress does.

See also

Released under the MIT License.