WP Manifestindependent plugin directory
manifest / performance / peregrine

Peregrine

Peregrine — WordPress cache and performance plugin. Dependency-tracked invalidation instead of URL heuristics. Pixelz360.

by Pixelz360 · github.com/ahsan0211/peregrine · website

0stars
0forks

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.zip

A 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's require is PHP alone), so there is nothing for the declared humbug/php-scoper dev 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-FPM docker-compose stack for the NginxFastCgi layer adapter
  • PHPUnit: tests/Unit (no WordPress bootstrap, the default suite), tests/Integration (a real WP_UnitTestCase suite, its own phpunit-integration.xml.dist), and tests/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 namespace Peregrine, prefix prg_, text domain peregrine, cache directory wp-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, never config.php. Avoids colliding with hosting panels and other plugins that already use the bare name directly in wp-content. See docs/README.md.
  • install() may overwrite a foreign drop-in; verify_and_heal() never does. Deliberate asymmetry: activation is an explicit operator action, the admin_init self-heal is ambient. See Installer's own STATUS_FOREIGN docblock 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/why share their actual logic with the REST API's own PurgeController/WhyController via Invalidation\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 with hash_equals()) as an alternative to amanage_optionssession, for CI/CD and external callers — every other route needs a real session. See docs/rest-api.md` for the full contract, every error shape included.
  • X-Peregrine-Cache is sent on every cacheable response, not only a HIT. HIT, STALE, MISS, or BYPASS(reason) — the reason is always one of a fixed, header-safe set (BypassReason), never raw user input.
  • WP_CLI is a stub, on purpose, never a Composer dependency. The real class only exists inside an actual wp process; PHPStan is told its shape via phpstan-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-FPM docker-compose flavour exists solely for the NginxFastCgi layer adapter, and is not yet proven end-to-end (see docs/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.