Peregrine
Peregrine — WordPress cache and performance plugin. Dependency-tracked invalidation instead of URL heuristics. Pixelz360.
Install
No release zip yet. The repository archive installs, but the folder name will carry the branch suffix and updates will not flow:
wp plugin install https://github.com/ahsan0211/peregrine/archive/refs/heads/main.zipA WordPress cache and performance plugin, built to be correct by construction on invalidation instead of the URL-heuristic guessing every existing cache plugin relies on ("the cache did not clear" stops being a support category) and honest about its own health in the dashboard before it is honest about it with a client.
Phase 1 is done: this is a real, working cache and invalidation engine, not
a scaffold. A page gets cached, purged precisely (never the whole site for
one price change), and re-served correctly with no cron in the loop — all of
it verified against a real, running WordPress site, not only asserted by a
test suite. What's built: the page cache engine and drop-in, dependency-
tracked invalidation (three-level post tags, future-dated scheduled purges),
the no-cron-dependence job queue, six layer adapters (local disk, Cloudflare,
LiteSpeed, Nginx, Varnish, five host APIs), WooCommerce/Elementor/ACF/
WPML-Polylang integrations, compatibility shims for WP Rocket/W3TC/WP Super
Cache/LiteSpeed's own purge calls, the REST API, WP-CLI, and a React admin
app (Dashboard, Cache, Invalidation, Layers, Tools). See "Decisions worth
knowing" below for exactly what's real today versus named as later-phase
work, and docs/ for the fuller written record.
Stack
- PHP 8.1+ (typed, enums, readonly), WordPress 6.6+ tested through 7.1, MySQL 8 / MariaDB 10.6+
- Composer, PSR-4 (
Peregrine\→src/). No runtime third-party dependency exists yet (composer.json'srequireis PHP alone), so there is nothing for the declaredhumbug/php-scoperdev dependency to scope — it stays unwired until a real runtime package is added. - Coding standards: WPCS via PHPCS (
WordPress-Extra+PHPCompatibilityWP,testVersion=8.1-), PHPStan level 6 with the WordPress extension (szepeviktor/phpstan-wordpress) @wordpress/env(Docker) for local WordPress — the default Apache flavour, plus a separate Nginx + PHP-FPMdocker-composestack for theNginxFastCgilayer adapter- PHPUnit:
tests/Unit(no WordPress bootstrap, the default suite),tests/Integration(a realWP_UnitTestCasesuite, its ownphpunit-integration.xml.dist), andtests/E2E(Playwright) — all real, all populated; see "Tests" below for what's actually been run where - The React admin app:
@wordpress/scripts/@wordpress/components/@wordpress/element/@wordpress/api-fetch/@wordpress/i18n, plain JS+JSX (matching@wordpress/scripts's own default, not TypeScript)
How it is put together
Fail open, from the very first line. peregrine.php checks the PHP and
WordPress version floor in deliberately old-syntax-safe PHP before it
requires the Composer autoloader, so a host running below the floor gets a
plain admin notice, never a fatal — and nothing under src/ (which does use
PHP 8.1 syntax) is ever parsed on a host that could not parse it. The same
shape repeats everywhere real work happens: Db\Migrator and
DropIn\Installer catch their own failures and degrade to "nothing changed"
rather than half-applying something and staying silent about it; the
advanced-cache.php drop-in wraps every real decision in try/catch and falls
through to an ordinary WordPress render on any fault. Verified live, not just
by design: moving the entire plugin directory out from under a running site
left it serving normally, zero fatals, zero log entries.
Plugin is the one composition root. Container is a minimal service
locator (set()/get(), no autowiring); Plugin::instance() builds it once
per request. Every other class takes its dependencies directly in its
constructor — the container itself is never passed further down.
Settings are a schema, not a bag of options. Settings\Schema owns the
prg_settings option's defaults; merging a stored value back over those
defaults drops any key the schema no longer defines and any value whose type
does not match its default's type, falling back to the default instead of
coercing. That is what makes an old settings blob forward-compatible with a
later schema change for free.
The cache engine is a read path and a write path that must never
disagree. Cache\RequestEngine (pure PHP, no WordPress calls — it runs
inside advanced-cache.php before WordPress exists) decides bypass, miss,
hit, or a bounded stale-while-regenerate serve, purely from arguments the
drop-in hands it. Cache\Engine (the WordPress-side write path) captures a
cacheable render via an output-buffer callback — not a shutdown hook, which
core's own wp_ob_end_flush_all() would close first — and commits it once
WordPress finishes. Both sides derive the exact same cache key from the same
site-fixed home_url() values, and both refuse to serve or write across a
Host header that does not match the configured site, so a domain-mapped
sub-site or a spoofed Host can never poison or leak the wrong cache entry.
Invalidation is tag-based and diff-aware, not a URL guess. A body-only
edit bumps post:{id}; a title/excerpt/thumbnail/status change also bumps
post-summary:{id}; a meta change (price, stock, an ACF field) bumps
post-meta:{id} — three different edits, three different purge scopes, never
one blanket flush. Invalidation\TagVersions is the correctness core: an
entry is stale when t0 < version <= now for any tag it carries, checked at
request time with no daemon or queue in the path. A version set in the
future is a scheduled purge that applies itself with no cron at all —
verified live end to end with a real WooCommerce sale and DISABLE_WP_CRON
true throughout.
No layer purges without an audit trail. Invalidation\Audit logs every
purge — event, tags, affected count, which layer, and the result — and
wp peregrine why <url> / the admin app's own Freshness tool read it back
with an explicit, honest disclaimer: it proves tag overlap, never a precise
per-URL purge history, because the table itself has no urls column.
Doctor is the one place health is decided, twice. Every check is a public
check_*() that gathers real facts and a private evaluate_*() that turns
already-known facts into a result, so the pass/fail decision is unit-testable
with hand-fed facts. The same checks feed Site Health, wp peregrine doctor,
and the admin app's own Dashboard tab.
Setup
1. Install dependencies
composer install
npm install
2. Local WordPress — Apache (default)
npm run wp-env:start # WordPress 7.1, PHP 8.3, WooCommerce, Twenty Twenty-Five + Twenty Seventeen
npx wp-env run cli wp plugin list
npx wp-env run cli wp peregrine status
npx wp-env run cli wp peregrine doctor
npx wp-env run cli wp peregrine --help
npm run wp-env:stop
The site is at http://localhost:8888 (wp-admin credentials are
@wordpress/env's own defaults). .wp-env.json pins the core/PHP versions
used above; .wp-env.override.json (gitignored) is how CI's compatibility
matrix overrides the PHP version per cell.
3. Local WordPress — Nginx + PHP-FPM
For the NginxFastCgi layer adapter, which needs a real Nginx front end
talking FastCGI to PHP-FPM (@wordpress/env's own WordPress container is
always Apache):
npm run wp-env:start:nginx
npm run wp-env:nginx:install
npm run wp-env:stop:nginx
Visit http://localhost:8890. Structurally validated
(docker compose -f docker-compose.nginx.yml config); not yet run
end-to-end against a real Nginx+FastCGI purge in this repository — see
docs/README.md.
4. The admin app
npm run build # compiles assets/src/admin -> assets/build (production)
npm run start # watch mode
Loads at Peregrine in the wp-admin menu once the plugin is active and
assets/build/ exists.
Tests
composer test # PHPUnit, tests/Unit -- 1397 tests, 5860 assertions, no WordPress required
composer test:integration # PHPUnit, tests/Integration -- real WP_UnitTestCase
npm run test:unit # Jest, the admin app -- 53 tests
npx playwright test tests/E2E/spec-invalidation.spec.ts # against a real, running WordPress instance (WP_BASE_URL)
tests/Unit needs no WordPress bootstrap at all (tests/bootstrap.php
stands in for the handful of Options-API/__()/esc_html() calls Phase 0's
logic needs); it is what CI and composer test both run by default.
tests/Integration is a real WP_UnitTestCase suite (its own
phpunit-integration.xml.dist, WP_TESTS_DIR required) — confirmed
genuinely passing twice this build against a real, non-Docker WordPress
core + wordpress-develop test-library checkout (this container has no
Docker daemon, so @wordpress/env cannot build one; that recipe is written
up in the plan document's own "Feasibility check" section).
tests/E2E (spec-invalidation.spec.ts) has been executed for real, once,
against a genuine non-Docker WordPress+WooCommerce+ACF+Elementor instance —
see tests/E2E/README.md for the honest, current pass/fail breakdown (two
real, narrow issues remain open there, neither a correctness defect in the
plugin itself).
spec/invalidation/fixtures/*.php is the single source of truth both PHPUnit
tiers (and the Playwright spec) consume — one oracle fixture per scenario,
not three independently-maintained copies of the same expectation.
composer lint # PHPCS: WordPress-Extra + PHPCompatibilityWP
composer lint:dropin # PHPCS: a separate PHP-7.0-floor scan of advanced-cache.php.tpl only
composer analyse # PHPStan level 6 + the WordPress extension
npm run lint:js # ESLint via @wordpress/scripts
npm run typecheck # tsc --noEmit -- covers playwright.config.ts/tests/E2E/**/*.ts only;
# does NOT typecheck assets/src/admin -- see docs/README.md for why
.github/workflows/peregrine.yml runs the PHP toolchain across a PHP
8.1–8.5 matrix, plus a job that boots real WordPress 6.6/7.0/7.1 via wp-env
to prove the plugin activates cleanly, and an unpinned composer update
drift check.
Layout
peregrine/
peregrine.php bootstrap: PHP/WP version guard, autoloader guard, hooks
uninstall.php removes prg_* tables/options, opt-in only
composer.json composer.lock package.json package-lock.json
.wp-env.json .wp-env.nginx.json docker-compose.nginx.yml docker/nginx/default.conf
phpcs.xml.dist phpcs-dropin.xml.dist phpstan.neon.dist
phpunit.xml.dist phpunit-integration.xml.dist playwright.config.ts
webpack.config.js jest-unit.config.js tsconfig.json
src/
Plugin.php Container.php
Settings/ Schema.php Settings.php
Db/ Schema.php Migrator.php Uninstaller.php
DropIn/ advanced-cache.php.tpl ConfigWriter.php Installer.php
Health/ Status.php CheckResult.php Doctor.php (7 checks)
Cli/ CommandLoader.php + one *Command.php per `wp peregrine` subcommand
Cache/ Engine.php RequestEngine.php Response.php FileStore.php Gc.php
KeyBuilder.php UrlKey.php Lock.php Variants.php Compression.php
Cacheability.php BypassReason.php Runners/DailyGc.php
Invalidation/ PurgePipeline.php TagVersions.php Index.php Audit.php
EventCatalogue.php PostDiff.php Explainer.php Purger.php
Layers/ Registry.php Adapter.php LayerResult.php
Local.php Cloudflare.php LiteSpeed.php Nginx.php Varnish.php HostApi.php
Preload/ Preloader.php PreloadHandler.php Runners/DailyTick.php Sources/HomeSource.php
Queue/ JobStore.php Worker.php Runners/{Cli,Cron,Loopback,Shutdown}.php
Tracking/ Tracker.php (the per-object tag recorder)
Integrations/ Acf.php Elementor.php Multilingual.php WooCommerce.php
Compat/ Shims.php + functions.php (WP Rocket/W3TC/WP Super Cache/LiteSpeed purge calls)
Rest/ Controller.php + one *Controller.php per /peregrine/v1/* route
Admin/ Screen.php (menu registration, asset enqueue, nonce localisation)
Rum/ Store.php Beacon.php (Phase 3 Wave 0)
Fragment/ Store.php Engine.php functions.php Holes.php (Phase 3 Wave 1)
ObjectCache/ Store.php Engine.php Installer.php (Phase 3 Wave 2.1)
Import/ Rocket.php LiteSpeed.php W3TC.php Engine.php (Phase 3 Wave 2.3)
Optimise/
index.php "Silence is golden" guard, matching WordPress's own
assets/src/admin/ the React app -- components/, pages/, api.js, use-fetch.js, style.css
assets/build/ compiled output (gitignored; npm run build produces it)
spec/invalidation/ fixtures/*.php (10 oracle scenarios) + README.md
tests/
bootstrap.php Options API + __()/esc_html() stand-ins, no WordPress
Unit/ mirrors src/ 1:1, no WordPress bootstrap
Integration/ real WP_UnitTestCase, consumes spec/invalidation/'s own fixtures
E2E/ spec-invalidation.spec.ts (Playwright), README.md
Load/ k6 + a stampede-test script for TTFB/req-s/concurrency checks
docs/
README.md local-environment notes, naming decisions, known gaps
rest-api.md a maintained contract reference for every REST route
rollout.md stub -- filled in for a real client rollout
Decisions worth knowing
- The identifiers are load-bearing, not cosmetic. Product name
Peregrine, slug
peregrine, PHP namespacePeregrine, prefixprg_, text domainperegrine, cache directorywp-content/cache/peregrine/are baked into the schema, hooks, options and file paths. Renaming any of them later is a migration, not a find-and-replace. peregrine-config.php, neverconfig.php. Avoids colliding with hosting panels and other plugins that already use the bare name directly inwp-content. Seedocs/README.md.install()may overwrite a foreign drop-in;verify_and_heal()never does. Deliberate asymmetry: activation is an explicit operator action, theadmin_initself-heal is ambient. SeeInstaller's ownSTATUS_FOREIGNdocblock before changing either.- Every
wp peregrine ...command is real:status,doctor,purge [--url|--tag|--post|--all],preload [--sitemap|--urls],worker run,index rebuild,why <url>,settings get|set|export|import,db-cleanup,import [<source>] [--dry-run].purge/whyshare their actual logic with the REST API's ownPurgeController/WhyControllerviaInvalidation\Purger/Explainer, so CLI and REST can never silently drift on the same operation. - *The REST API (`/peregrine/v1/
) has a separate purge-token auth path.**POST /purgeaccepts anX-Peregrine-Purge-Tokenheader (checked withhash_equals()) as an alternative to amanage_optionssession, for CI/CD and external callers — every other route needs a real session. Seedocs/rest-api.md` for the full contract, every error shape included. X-Peregrine-Cacheis sent on every cacheable response, not only a HIT.HIT,STALE,MISS, orBYPASS(reason)— the reason is always one of a fixed, header-safe set (BypassReason), never raw user input.WP_CLIis a stub, on purpose, never a Composer dependency. The real class only exists inside an actualwpprocess; PHPStan is told its shape viaphpstan-stubs/wp-cli.stub.- Two local WordPress stacks exist for two different reasons. The
default Apache flavour (
@wordpress/env) is for everyday development; the Nginx + PHP-FPMdocker-composeflavour exists solely for theNginxFastCgilayer adapter, and is not yet proven end-to-end (seedocs/README.md). - Not yet built: Phase 4 (used CSS / measured ATF, cache rules,
delay-JS packs, Cloudflare tags, competitive gate). Phases 0–3 are
closed. See
docs/phase4-progress.md.