# PressGang

PressGang anchors your workflow in modern development practices. Build cleaner, faster, smarter themes and navigate your development course to calmer seas!

## Overview

PressGang is a powerful and flexible WordPress parent theme framework designed to streamline theme development and enhance customization capabilities. As a parent theme framework, PressGang serves as a foundation upon which child themes can be built, allowing developers to create custom themes more efficiently while inheriting the robust features and structure of the PressGang parent theme.

Think of PressGang as your ship's hull — it handles the heavy structural work so your child theme can focus on what makes your site unique.

### Key Features

1. **Rapid Development:** Engineered for quickly building WordPress themes with clean and modern coding standards, accelerating the development process by providing a solid foundation and tools.
2. **Timber Integration:** Utilizes the [Timber library](https://timber.github.io/docs/v2/), separating template code from PHP logic through the Twig templating engine, resulting in cleaner, more maintainable code.
3. **MVC-inspired Architecture:** Introduces the concept of Controllers to WordPress theme development, expanding on the Model-View-Controller (MVC) approach for better organization and separation of concerns.
4. **Convention over Configuration:** Inspired by frameworks like Laravel, PressGang lets you bootstrap repetitive WordPress tasks via configuration files, reducing boilerplate code and increasing developer productivity.
5. **Composer and PSR-4:** Utilizes Composer for dependency management and adheres to PSR-4 autoloading standards (`PressGang\` -> `src/`), ensuring a consistent and modern code structure.
6. **Flexibility and Customization:** Maintains the core WordPress structure, allowing developers to leverage their existing WordPress knowledge while benefiting from additional features. It provides powerful tools and conventions but remains flexible for direct interaction with WordPress as needed.

By leveraging these features, PressGang empowers developers to create sophisticated, performant, and maintainable WordPress themes with greater ease and efficiency.

### Prerequisites

{% hint style="info" %}
To make the most out of PressGang, familiarity with the following tools is recommended
{% endhint %}

* [Timber](https://timber.github.io/docs/v2/)
* [Twig](https://twig.symfony.com/doc/3.x/)
* [Composer](https://getcomposer.org/)

### Requirements

* PHP 8.3+
* WordPress 6.4+
* Timber 2.0+

## Getting Started

PressGang is designed as a WordPress *parent theme* that acts as a library for your [*child theme*](https://developer.wordpress.org/themes/advanced-topics/child-themes/).

{% hint style="success" %}
To get started, you will need to create a child theme. All hands on deck!
{% endhint %}

{% tabs %}
{% tab title="Quick Start" %}
The fastest way to get up and running is by using our [pressgang-child](https://github.com/pressgang-wp/pressgang-child) repository, which provides a ready-made child theme scaffold.

{% stepper %}
{% step %}

#### Clone the repository

{% code title="Terminal" %}

```bash
git clone https://github.com/pressgang-wp/pressgang-child your-theme-name
```

{% endcode %}
{% endstep %}

{% step %}

#### Navigate into your theme

{% code title="Terminal" %}

```bash
cd your-theme-name
```

{% endcode %}
{% endstep %}

{% step %}

#### Install dependencies

{% code title="Terminal" %}

```bash
composer install
```

{% endcode %}
{% endstep %}

{% step %}

#### Start developing

Follow the instructions in the README to set up your environment and start developing your child theme.
{% endstep %}
{% endstepper %}
{% endtab %}

{% tab title="Manual Setup" %}
If you prefer to start from scratch, you can manually set up your PressGang environment.

{% stepper %}
{% step %}

#### Clone PressGang

{% code title="Terminal" %}

```bash
git clone https://github.com/pressgang-wp/pressgang
```

{% endcode %}
{% endstep %}

{% step %}

#### Create your child theme

Create your own child theme by following the [WordPress guidelines for child themes](https://developer.wordpress.org/themes/advanced-topics/child-themes/).
{% endstep %}
{% endstepper %}
{% endtab %}

{% tab title="Composer" %}
You can also include PressGang as a dependency in your project using Composer.

{% stepper %}
{% step %}

#### Require the package

{% code title="Terminal" %}

```bash
composer require pressgang-wp/pressgang
```

{% endcode %}
{% endstep %}

{% step %}

#### Configure your child theme

Create and configure your child theme to extend the PressGang parent theme. For detailed instructions, refer to the [PressGang documentation](https://github.com/pressgang-wp/pressgang).
{% endstep %}
{% endstepper %}
{% endtab %}
{% endtabs %}

## License

PressGang is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).


# Boot Sequence

Understanding how PressGang starts up helps you know where things happen and — just as importantly — where they should *not* happen.

## Overview

PressGang boots from `functions.php` in three clean stages. Think of it as raising the anchor, setting the sails, and catching the wind:

```mermaid
graph TD
    A["functions.php"] --> B["PressGang::boot()"]
    B --> C["1. Timber::init()"]
    B --> D["2. Loader::initialize()"]
    B --> E["3. Service providers"]
    C --> C1["Initialize Timber library"]
    D --> D1["Load config files"]
    D --> D2["Register components"]
    E --> E1["Boot classes from config/service-providers.php"]
    E --> E2["Filter via pressgang_service_providers"]
    E --> E3["TimberServiceProvider::boot() by default"]
```

## Stage by Stage

{% stepper %}
{% step %}

#### 1. Composer Autoload

Before anything else, `functions.php` loads the Composer autoloader and defines the `THEMENAME` constant used for translations:

{% code title="functions.php" lineNumbers="true" %}

```php
if (!defined('THEMENAME')) {
    define('THEMENAME', 'pressgang');
}

$autoload_path = get_stylesheet_directory() . '/vendor/autoload.php';
if (file_exists($autoload_path)) {
    require_once $autoload_path;
}

(new PressGang\PressGang(
    new Loader(new FileConfigLoader())
))->boot();
```

{% endcode %}

{% hint style="info" %}
Your child theme's `functions.php` should override `THEMENAME` with your own text domain before requiring the autoloader.
{% endhint %}
{% endstep %}

{% step %}

#### 2. Timber Initialization

`Timber::init()` sets up the Timber library — connecting Twig to WordPress and preparing the template rendering pipeline.
{% endstep %}

{% step %}

#### 3. Loader Initialization

The `Loader` performs two tasks:

**a) Load Components**

The `FileConfigLoader` reads every `*.php` file from the parent theme's `config/` directory, then merges in files from the child theme's `config/` directory (child overrides parent). The merged settings are cached for performance.

For each config key, the Loader converts it to a Configuration class name (e.g. `custom-post-types` → `CustomPostTypes`) and calls `initialize()` on the singleton instance:

{% code title="Config → Class mapping" %}

```
config/sidebars.php         → Configuration\Sidebars::get_instance()->initialize($config)
config/custom-post-types.php → Configuration\CustomPostTypes::get_instance()->initialize($config)
config/scripts.php          → Configuration\Scripts::get_instance()->initialize($config)
```

{% endcode %}

**b) Include Files**

Shortcode and widget classes listed in `config/shortcodes.php` and `config/widgets.php` are included and registered. These use a different mechanism — they're `require`'d and instantiated directly rather than going through the Configuration singleton pattern.
{% endstep %}

{% step %}

#### 4. Service Providers

Service providers are loaded from `config/service-providers.php` (then filtered by `pressgang_service_providers`).

By default this includes `TimberServiceProvider`, which wires up:

1. **Context Managers** — classes listed in `config/context-managers.php` are instantiated and hooked into `timber/context` to enrich every page's context.
2. **Twig Extensions** — classes listed in `config/twig-extensions.php` are instantiated and hooked into `timber/twig` to add custom functions, filters, and globals.
3. **Twig Environment Options** — `config/timber.php` is applied to `timber/twig/environment/options` (child themes can enable or disable Twig compilation cache per site).
4. **Snippet Template Paths** — the `pressgang-snippets` vendor views directory is added to Timber's template locations.
   {% endstep %}
   {% endstepper %}

## Performance Rules

{% hint style="danger" %}
Never perform queries, I/O, or remote requests during boot. The boot sequence runs on every request — keep it fast!
{% endhint %}

* Config files must return arrays only — no queries, no logic branches, no side effects.
* Context managers should cache any non-trivial data (see [Context Managers](/context-managers)).
* Config is cached via `wp_cache` or transients (configurable with `PRESSGANG_CONFIG_CACHE_SECONDS`).
* To force a config reload: `PressGang\Bootstrap\Config::clear_cache()`.

## Filtering the Boot Process

Several hooks let you customize the boot:

| Hook                              | Type   | Purpose                                                         |
| --------------------------------- | ------ | --------------------------------------------------------------- |
| `pressgang_config_directories`    | filter | Modify the list of config directories to load from              |
| `pressgang_get_config`            | filter | Filter the merged config array after loading                    |
| `pressgang_include_directories`   | filter | Modify where the Loader looks for shortcode/widget files        |
| `timber/context`                  | filter | Add data to the global Timber context                           |
| `timber/twig`                     | filter | Add functions/filters/globals to the Twig environment           |
| `timber/twig/environment/options` | filter | Configure Twig environment options (cache, auto\_reload, debug) |
| `pressgang_service_providers`     | filter | Modify the list of service providers to boot                    |


# Config

## Centralized Configuration Management

PressGang adopts a centralized approach to configuration, storing settings in dedicated files within the `config` directory. This structure simplifies the management and updating of theme settings, ensuring a clean and organized codebase.

No need to wrestle with tangled `functions.php` files — PressGang lets you chart your course with clean, declarative config.

### How It Works

1. **Config Files:** Individual PHP files within the `config` directory return associative arrays that define settings for various theme components.
2. **Loading and Configuration:** Loading of `config` files is handled in the `Bootstrap` namespace. The `FileConfigLoader` (implementing `ConfigLoaderInterface`) reads and merges configuration settings, supporting hierarchical overrides — child theme config always takes precedence over parent theme config. The `Configuration` namespace provides singleton classes that receive the config settings and apply the necessary logic.
3. **Central Access Point:** The `Config` class provides static methods to retrieve settings, ensuring a single point of access and enabling caching for performance.

### The Config Lifecycle

```mermaid
graph LR
    A["config/*.php<br/>(arrays)"] --> B["FileConfigLoader<br/>(merge/cache)"]
    B --> C["Config::get()<br/>(access)"]
    C --> D["Configuration\\* classes<br/>(registration)"]
```

Each config file maps to a Configuration class by studly-case name:

* `config/sidebars.php` → `PressGang\Configuration\Sidebars`
* `config/custom-post-types.php` → `PressGang\Configuration\CustomPostTypes`

The class **must exist** — a config file alone does nothing without a corresponding Configuration class.

### Benefits for Theme Development

{% hint style="success" %}
Convention over configuration reduces overhead and keeps your theme maintainable.
{% endhint %}

* **Streamlined Workflow:** Convention over configuration reduces the overhead associated with setting up and maintaining theme configurations.
* **Enhanced Maintainability:** The clear separation of configuration concerns into dedicated files makes the codebase easier to navigate and maintain.
* **Flexibility:** Developers can easily extend and customize the theme by adding new configuration files or modifying existing ones. Child theme config overrides parent theme config — no hooks or filters needed.

## Config Files

All files are present in the `config` folder of the PressGang theme. These can be overridden and modified to uniquely configure your child theme by following the same directory structure.

### Configuration Classes

These config files map to a singleton Configuration class that registers the defined items with WordPress:

<details>

<summary><strong>Content Types</strong></summary>

`custom-post-types.php` Registers and configures custom post types.

`custom-taxonomies.php` Defines and registers custom taxonomies.

`templates.php` Registers custom page templates for the Page Attributes dropdown.

`timber-class-map.php` Maps WordPress post types to custom Timber post classes.

</details>

<details>

<summary><strong>Navigation &#x26; Layout</strong></summary>

`menus.php` Registers navigation menus.

`sidebars.php` Registers widget sidebars.

`custom-menu-items.php` Registers custom menu item types.

</details>

<details>

<summary><strong>Editor &#x26; Blocks</strong></summary>

`blocks.php` Registers Gutenberg blocks. See the [Blocks](/blocks) page for details.

`block-categories.php` Registers custom block categories for the Gutenberg editor.

`block-patterns.php` Defines and registers block patterns.

`color-palette.php` Configures custom color palettes for the editor.

</details>

<details>

<summary><strong>Assets</strong></summary>

`scripts.php` Registers and enqueues scripts.

`styles.php` Registers and enqueues styles.

`preloads.php` Declares resources using WordPress's `wp_preload_resources` filter. The parent default is empty; a child theme opts in by returning a list of resource definitions. Existing plugin preloads are preserved. WordPress handles output, escaping, and duplicate suppression.

```php
<?php
return [
    [
        'href' => get_stylesheet_directory_uri() . '/fonts/text.woff2',
        'as' => 'font',
        'type' => 'font/woff2',
        'crossorigin' => 'anonymous',
    ],
];
```

Use WordPress's native resource attributes, including `media`, `imagesrcset`, `imagesizes`, and `fetchpriority` where appropriate. Preloads initiate downloads; include only resources needed early in the page. Font preloads should match the CSS font URL and include `crossorigin`. This does not register or enqueue scripts or stylesheets, and does not replace those configurations.

[WordPress preload API](https://developer.wordpress.org/reference/hooks/wp_preload_resources/)

`dequeue-styles.php` Handles dequeueing of unwanted styles.

`deregister-scripts.php` Manages deregistration of unwanted scripts.

</details>

<details>

<summary><strong>Theme Features</strong></summary>

`support.php` Adds theme support features (e.g. `post-thumbnails`, `title-tag`).

`remove-support.php` Handles removal of theme support features.

`customizer.php` Registers WordPress Customizer sections and settings.

</details>

<details>

<summary><strong>Admin</strong></summary>

`remove-menus.php` Configures removal of specific admin menus.

`remove-nodes.php` Manages removal of admin bar nodes.

`query-vars.php` Registers custom query variables.

</details>

<details>

<summary><strong>Integrations &#x26; Misc</strong></summary>

`acf-options.php` Registers Advanced Custom Fields (ACF) options pages.

`actions.php` Registers custom actions within the theme.

`meta-tags.php` Manages meta tag configurations.

`plugins.php` Manages required/recommended plugin declarations.

`controllers.php` Maps template hierarchy candidates to controllers when a name defies convention. See [Template Routing](/template-routing).

`page-templates.php` Registers file-less page templates. See [Template Routing](/template-routing).

`routes.php` Configures custom routes — template filenames or `RouteHandlerInterface` classes. See [Template Routing](/template-routing).

`snippets.php` Configures snippet class loading. See the [Snippets](/snippets) page.

</details>

### Service Provider Config

These config files are consumed by the `TimberServiceProvider` rather than by Configuration classes:

`context-managers.php` Lists context manager classes for enriching the Timber context. See [Context Managers](/context-managers).

`twig-extensions.php` Lists Twig extension manager classes for adding custom functions, filters, and globals to Twig. See [Twig Extensions](/twig-extensions).

`timber.php` Configures Timber Twig environment options via `timber/twig/environment/options` (including Twig compilation cache). Child themes can override this file to enable or disable caching per site.

`service-providers.php` Lists bootable service provider class strings. PressGang boots these after Timber init + Loader initialize; by default this list includes `\PressGang\ServiceProviders\TimberServiceProvider::class`. This list is also filterable via `pressgang_service_providers`.

### Include-based Config

These config files list class names to be auto-included and registered by the `Loader`, rather than going through the Configuration singleton pattern:

`shortcodes.php` Lists shortcode classes under `src/Shortcodes/` to be included and instantiated.

`widgets.php` Lists widget classes under `src/Widgets/` to be included and registered.

### Legacy Config

`nodes.php` This file exists for backward compatibility. Node removal is handled by `remove-nodes.php` via the `RemoveNodes` configuration class.

## Example Usage

Here is an example of registering a custom post type via the config. The associative array arguments match the `register_post_type` args.

{% code title="config/custom-post-types.php" lineNumbers="true" %}

```php
return [
    'event' => [
        'label' => 'Events',
        'description' => 'A custom post type for events.',
        'public' => true,
        'has_archive' => true,
        'menu_icon' => 'dashicons-calendar',
        'supports' => ['title', 'editor', 'excerpt', 'custom-fields'],
        'taxonomies' => ['category', 'post_tag'],
        'rewrite' => [
            'slug' => 'events',
            'with_front' => false
        ],
        'show_in_rest' => true,
    ],
];
```

{% endcode %}

### Overriding in a Child Theme

To override a parent theme config, create the same file in your child theme's `config/` directory. Your file's array will be merged on top of the parent's — later values win.

{% code title="child-theme/config/support.php" %}

```php
return [
    'post-thumbnails',
    'title-tag',
    'custom-logo',
];
```

{% endcode %}

### Controller template mappings

Use a parent controller when a view needs no additional context. In `config/controllers.php`, map a hierarchy candidate to a class string or `[ 'controller' => PageController::class, 'template' => 'page/research-subpage.twig' ]` (with the appropriate imported or fully qualified controller class). The explicit Twig path takes precedence over candidate discovery. Omitting `template`, or setting it to null, retains candidate discovery and constructor defaults. Candidate specificity and physical child PHP template precedence are unchanged. Keep custom controllers for additional getters, traits or behaviour. Before removing a class, check its class-derived rendering filters and action consumers; reusing the parent changes those hook names. This does not accept arbitrary constructor arguments.


# Controllers

## Overview

In PressGang, controllers manage the display logic for different types of pages and templates, often reflecting the WordPress template hierarchy, e.g., `PageController`. Conventionally, they use singular names (e.g., `PostController`) to represent single pages, and plural names (e.g., `PostsController`) to represent archive pages. Essentially, these classes build the Timber context that gets passed to the views (Twig templates).

Controllers are your first mates — they prepare everything the view needs, then hand it off cleanly.

### Naming child controllers

Use a singular name for a single post or page and a plural name for a collection or archive: `ConferenceController` and `ConferencesController`. The singular name already identifies a single view; do not add `Single` as a prefix or suffix. A listing backed by a WordPress page still uses the collection name, even when its controller extends `PageController`.

Name taxonomy controllers after their subject (`EventTypeController`). Keep purpose-based names for special views, such as `SearchController` and `NotFoundController`.

Controller names need not copy template filenames. A `conference-single.php` stub can render `ConferenceController` explicitly while retaining its Twig path and stored page-template ID. When renaming a class, update imports, render calls, controller maps and consumers of class-derived hooks. Verify routing: the optional dispatcher may require an explicit map for a legacy template name.

### AbstractController Base Class

The `AbstractController` class provides common functionalities for all controllers, including context management and template rendering.

### Key Methods

* `__construct(string|null $template = null)`: Initializes the controller with the specified Twig template and attaches the base `Timber::context()`.
* `get_context()`: Builds and returns the context array for the template.
* `render()`: Renders the Twig template with the current context, applying PressGang filters and actions.

### Available Controllers

PressGang ships with controllers for all the common WordPress template types:

| Controller           | Template        | Purpose                                          |
| -------------------- | --------------- | ------------------------------------------------ |
| `PageController`     | `page.twig`     | Standard WordPress pages                         |
| `PostController`     | `single.twig`   | Single post views (auto-detects post type)       |
| `PostsController`    | `archive.twig`  | Archive listings, categories, search results     |
| `SearchController`   | `search.twig`   | Search results (extends PostsController)         |
| `AuthorController`   | `author.twig`   | Author archive pages (extends PostsController)   |
| `TaxonomyController` | varies          | Taxonomy archive pages (extends PostsController) |
| `CommentsController` | `comments.twig` | Comments template                                |
| `NotFoundController` | `404.twig`      | 404 error page                                   |

WooCommerce controllers are also provided under `PressGang\Controllers\WooCommerce\`.

### WooCommerce Taxonomy Templates

WooCommerce hands every product archive to the theme's `woocommerce.php`, and `WC_Template_Loader` places that file **ahead of** `taxonomy-{taxonomy}.php` in its candidate list. Because PressGang ships a `woocommerce.php`, WordPress' per-taxonomy PHP template route is unavailable — a `taxonomy-product_brand.php` in your theme would be silently ignored.

`ProductsController` restores that route in the Twig layer. Product taxonomy archives resolve:

| Candidate                              | Used when                                |
| -------------------------------------- | ---------------------------------------- |
| `woocommerce/taxonomy-{taxonomy}.twig` | The theme provides one for that taxonomy |
| `woocommerce/archive-product.twig`     | Fallback for every product archive       |

So a promotions taxonomy registered as `product_promotion` picks up `views/woocommerce/taxonomy-product-promotion.twig` when present. Underscores become hyphens, matching `PostsController::infer_template()`. Themes without a taxonomy-specific template resolve exactly as before.

This applies to `product_cat` and `product_tag` as well as to any custom taxonomy registered against the `product` post type.

A product taxonomy can also take its own **controller**, resolved child-theme-first by the same `{Taxonomy}Controller` convention used elsewhere in PressGang:

| Taxonomy            | Controller                                            |
| ------------------- | ----------------------------------------------------- |
| `product_promotion` | `ProductPromotionController` or `PromotionController` |
| `product_brand`     | `ProductBrandController` or `BrandController`         |

The full taxonomy name is tried first, then the name with a leading `product_` stripped — that prefix is redundant inside the `Controllers\WooCommerce\` namespace.

`WooCommerceControllerResolver` tries that class before falling back to the shared `ProductsController`, so a theme can give one taxonomy bespoke context without overriding the controller that also serves the shop and every other product archive. Extend `ProductsController` to inherit the products query and shop sidebar.

## Example: `PageController`

{% code title="src/Controllers/PageController.php" lineNumbers="true" %}

```php
namespace PressGang\Controllers;

use Timber\Post;
use Timber\Timber;

class PageController extends AbstractController {
    protected Post $post;

    public function __construct(string|null $template = 'page.twig') {
        parent::__construct($template);
    }

    protected function get_post(): Post {
        if (empty($this->post)) {
            $this->post = Timber::get_post();
        }
        return $this->post;
    }

    protected function get_context(): array {
        $post = $this->get_post();
        $this->context['page'] = $post;
        $this->context['post'] = $post;
        return $this->context;
    }
}
```

{% endcode %}

## Usage in Templates

Controllers are utilized in standard WordPress template files. PressGang maintains the familiar WordPress template hierarchy — you still create `page.php`, `single.php`, `archive.php`, etc. — but instead of writing queries and HTML, you delegate to a controller.

{% tabs %}
{% tab title="PressGang::render() (Recommended)" %}
The static `render()` method resolves the controller and template for you:

{% code title="page.php" %}

```php
use PressGang\Controllers\PageController;

PressGang\PressGang::render(controller: PageController::class, twig: 'front-page.twig');
```

{% endcode %}

You can also let PressGang infer the controller automatically from the template filename:

{% code title="page.php" %}

```php
PressGang\PressGang::render(template: 'page.php');
```

{% endcode %}
{% endtab %}

{% tab title="Direct Instantiation" %}
For more control, instantiate the controller directly:

{% code title="front-page.php" %}

```php
use PressGang\Controllers\PageController;

(new PageController('front-page.twig'))->render();
```

{% endcode %}
{% endtab %}
{% endtabs %}

{% hint style="info" %}
Tired of writing stub files at all? With [Template Routing](/template-routing) enabled, requests resolve to controllers by naming convention — most themes need no template PHP files.
{% endhint %}

## Context Getters: Declare the Template Contract

Wiring context keys one line at a time gets old fast. Instead, declare the keys your template needs and let each one populate from its matching getter:

{% code title="src/Controllers/FrontPageController.php" lineNumbers="true" %}

```php
namespace MyTheme\Controllers;

use PressGang\Controllers\PageController;
use PressGang\Quartermaster\Quartermaster;

class FrontPageController extends PageController {

    /**
     * Template contract for front-page.twig: latest news and
     * events, each populated from its get_{key}() getter.
     *
     * @var array<int|string, string>
     */
    protected array $context_getters = [
        'news',
        'events',
    ];

    protected function get_news(): array {
        return Quartermaster::posts( 'post' )
            ->limit( 4 )
            ->toArray();
    }

    protected function get_events(): array {
        return Quartermaster::posts( 'event' )
            ->limit( 4 )
            ->toArray();
    }
}
```

{% endcode %}

Each plain entry calls `get_{key}()`; use `'key' => 'method'` to point a key at a differently-named getter. The manifest is applied after `get_context()` and before the `pressgang_{controller}_context` filter, so both extension points still work — and with a manifest, most controllers don't need a `get_context()` override at all.

{% hint style="info" %}
This is the controller counterpart to the `HandlesDynamicGetters` trait on models: getters own the *fetching*, the manifest declares which of them form the *template contract*. Keep it a declared list — the framework deliberately never auto-publishes getters, or your internal helpers would silently become template API.
{% endhint %}

Expose additional prepared data, not a duplicate of every model field. Read presentation-only metadata with `post.meta('intro_title')` or `term.meta()` in Twig, retaining the appropriate output escaping. Local Twig variables are useful for repeated fields. Keep query construction, relationship normalization and selection/enrichment rules in PHP.

A manifest invokes each entry once per application; it is not a general getter cache. Add a cache only when another getter or execution path needs the same result. Avoid one-use getter/resolver pairs. Before removing context keys, inspect inherited block bodies, includes, macro arguments, dynamic access and PHP hooks.

## Pagination belongs to the displayed collection

`PostsController` exposes `posts`, `pagination` and `page_title` for the current WordPress query — those three keys are the whole listing contract. Keep that inherited contract for ordinary archives. A custom PageController listing can instead pass pagination explicitly from its own collection:

```twig
{% include 'partials/modules/pagination.twig' with {
    pagination: news_items.pagination()
} %}
```

This removes a forwarding getter without changing the partial's input. `PostQuery::pagination()` already caches the pagination object. Keep the collection cache if PHP also uses it for a heading or enrichment; otherwise a single-use context getter need not cache its result. Do not build a second query for pagination, and do not add pagination to an unpaged listing.

## Working with ACF Values

ACF relationship and post-object fields return raw `WP_Post` objects or IDs — but Twig wants Timber posts. Convert with the same mapper the ACF options context manager uses:

{% code title="Controller" lineNumbers="true" %}

```php
use PressGang\ACF\TimberMapper;

protected function get_related(): array {
    return TimberMapper::to_timber_posts( $this->get_post()->meta( 'related_items' ) );
}
```

{% endcode %}

It accepts the raw field value directly (empty and `false` values are fine) and returns a clean array of `Timber\Post` objects. Prefer this explicit conversion over enabling Timber's global `timber/meta/transform_value` filter, which silently changes the return type of *every* `meta()` call — and doesn't reach values inside flexible-content or repeater sub-fields anyway.

## Filters and Actions

The `render()` method fires several hooks, giving you fine-grained control over any controller's output:

* **`pressgang_{controller}_template`** — filter the Twig template path before rendering.
* **`pressgang_{controller}_context`** — filter the context array before it reaches Twig.
* **`pressgang_render_{controller}`** — action fired just before `Timber::render()`.

The `{controller}` placeholder is the snake\_case version of the controller class name, e.g., `pressgang_page_controller_template`.

## Extending Controllers in Child Themes

To extend the functionality of a parent theme controller in a child theme, create a new controller class in the child theme that inherits from the parent controller.

{% code title="src/Controllers/ChildPageController.php" lineNumbers="true" %}

```php
namespace ChildTheme\Controllers;

use PressGang\Controllers\PageController;

class ChildPageController extends PageController {
    protected function get_context(): array {
        $context = parent::get_context();
        $context['custom_data'] = 'Additional data';
        return $context;
    }
}
```

{% endcode %}

Then use it in your child theme's template:

{% code title="child-theme/page.php" %}

```php
use ChildTheme\Controllers\ChildPageController;

PressGang\PressGang::render(controller: ChildPageController::class);
```

{% endcode %}

This setup allows the child theme to inherit and extend the logic defined in the parent theme controllers, promoting code reuse and maintainability.

### Note on MVC Abstraction

{% hint style="info" %}
While these controllers are named similarly to traditional MVC Controllers, they function more closely as **View Models**.
{% endhint %}

In classic MVC:

* **Model:** Handles data and business logic.
* **View:** Manages the display of information.
* **Controller:** Acts as an intermediary, handling user input, updating the Model, and refreshing the View.

In PressGang, the Controllers primarily prepare and manage context data for the View (Twig templates), aligning more with the View Model pattern. They focus on preparing data for the View without directly handling user input or business logic.

{% hint style="danger" %}
Controllers must **not** perform writes, remote requests, or access request globals like `$_GET` or `$_POST`.
{% endhint %}


# Views

In PressGang, views are responsible for rendering the HTML output for the front-end of the website. Views are created using the [Twig](https://twig.symfony.com/) templating engine, provided by the [Timber library](https://timber.github.io/docs/v2/). This approach separates the presentation logic from the business logic, promoting cleaner and more maintainable code.

Twig templates are the sails of your PressGang ship — they catch the context data prepared by your controllers and turn it into beautiful, well-structured HTML.

## Twig Templates

Twig is a modern templating engine for PHP, offering a clean syntax that is easy to read and write. Timber integrates Twig into WordPress, allowing you to use Twig templates for rendering your theme views.

See:

* [Twig Documentation](https://twig.symfony.com/doc/3.x/)
* [Timber Documentation](https://timber.github.io/docs/v2/)
* [Timber Getting Started](https://timber.github.io/docs/v2/getting-started/introduction/)

### Using Timber via PressGang Controllers to Render Views

While Timber provides a simple way to render Twig templates with context data (see the [Timber introduction](https://timber.github.io/docs/v2/getting-started/introduction/#a-view)), PressGang takes this a step further by introducing Controllers to prepare context data in your WordPress templates.

In a typical PressGang setup, the `AbstractController` takes a `$template` argument in its constructor for the Twig template name, and attaches the base `Timber::context()` to the `$context` class property:

{% code title="src/Controllers/AbstractController.php" %}

```php
use Timber\Timber;

abstract class AbstractController implements ControllerInterface {

    public function __construct(?string $template = null) {
        $this->template = $template;
        $this->context  = Timber::context();
    }

}
```

{% endcode %}

Each controller's `get_context()` method then enriches the context with page-specific data.

{% tabs %}
{% tab title="PostController" %}
The `PostController` adds the current post to the context under both `'post'` and a post-type-specific key:

{% code title="src/Controllers/PostController.php" %}

```php
protected function get_context(): array {
    $post = $this->get_post();

    $this->context['post']             = $post;
    $this->context[ $this->post_type ] = $post;

    return $this->context;
}
```

{% endcode %}

This means in your `single.twig` template, you can access the post as `{{ post }}` or by its type, e.g., `{{ event }}` for a custom post type called `event`.
{% endtab %}

{% tab title="PostsController" %}
The `PostsController` adds posts, pagination, and a page title for archive pages:

{% code title="src/Controllers/PostsController.php" %}

```php
protected function get_context(): array {
    $this->context['page_title']  = $this->get_page_title();
    $this->context['pagination']  = $this->get_pagination();
    $this->context['posts']       = $this->get_posts();

    return $this->context;
}
```

{% endcode %}
{% endtab %}
{% endtabs %}

## Escaping

Twig's auto-escaping is enabled by default. This means `{{ value }}` is automatically HTML-escaped — you do not need to call `esc_html()` or similar WordPress functions inside Twig.

* **Attributes:** `{{ value|e('html_attr') }}`
* **URLs:** `{{ value|e('url') }}`
* **Raw HTML:** `{{ value|raw }}` — only when the value has been sanitised in PHP and is explicitly intended to contain HTML.

{% hint style="danger" %}
Sanitise input in PHP before passing it to the context. Let Twig handle the escaping on output. Don't mix the two!
{% endhint %}

## Translations (i18n)

PressGang uses a single text domain constant, `THEMENAME`, which is available as a Twig global:

{% code title="views/example.twig" %}

```twig
{{ __('Read more', THEMENAME) }}
{{ __('View %s', THEMENAME)|format(post.title) }}
```

{% endcode %}

Do not concatenate translated strings — use format placeholders instead.

## Views Directory

The `views` folder in your child theme is by default the home of your Twig templates.

Timber will look for templates in the child theme first, then falls back to the parent theme (just like WordPress itself). See [Timber template locations](https://timber.github.io/docs/v2/guides/template-locations/).

The PressGang parent theme organises its views into the following subdirectories:

{% code title="views/" %}

```
views/
  layouts/     — Base page layouts (header, footer, body structure)
  macros/      — Reusable Twig macros
  partials/    — Reusable template fragments
  scaffold/    — Structural scaffolding templates
  shared/      — Shared components used across templates
```

{% endcode %}

You can follow this same structure in your child theme, or organise views however suits your project. Any file in your child theme's `views/` directory will take precedence over the parent theme's version.


# Template Routing

## Overview

Template routing lets requests find their controllers **by convention** — most themes need no template PHP files at all. No more three-line stub files whose only job is naming a controller and a Twig template: the filename already told us everything.

Think of it as the ship's watch rota: every request already knows its station.

Template routing is **opt-in**. Enable it by adding the service provider to your child theme's `config/service-providers.php`:

{% code title="config/service-providers.php" lineNumbers="true" %}

```php
return [
    // PressGang defaults — always keep these
    \PressGang\ServiceProviders\TimberServiceProvider::class,
    \PressGang\ServiceProviders\SeoServiceProvider::class,

    // Opt-in: convention-based template routing
    \PressGang\ServiceProviders\TemplateRoutingServiceProvider::class,
];
```

{% endcode %}

{% hint style="info" %}
Themes built on explicit template stubs are completely untouched by framework upgrades — nothing changes until you list the provider. And even once enabled, **a physical template file in your child theme always wins**, so you can adopt convention routing incrementally: delete stubs one at a time.
{% endhint %}

## How It Works

When WordPress resolves a request, PressGang records the template hierarchy candidates (most specific first). If the request falls through to a **parent-theme** template — meaning your child theme had no stub for it — the dispatcher resolves a controller from those candidates and renders it.

Mechanically: `template_include` must return a PHP file for WordPress to load, so the dispatcher hands it the parent theme's `dispatch.php` — a one-line landing file that calls `ControllerFactory::dispatch()`. The routing *decision* happens in the filter; `dispatch.php` is just where the request touches down. (Custom route handlers reuse the same landing file — see below.)

For each candidate, resolution tries:

1. **Your config map** — an explicit entry in `config/controllers.php`
2. **Naming convention** — a matching controller in your child theme's `Controllers` namespace

The matching `{candidate}.twig` in your `views/` directory renders automatically; if it doesn't exist, the controller's own template inference applies.

## The Naming Conventions

Beyond the literal StudlyCase name, the dispatcher understands WordPress template hierarchy semantics — matching the PressGang convention of **plural controllers for archives, singular for single views**:

| Candidate             | Resolves to           | Rule                               |
| --------------------- | --------------------- | ---------------------------------- |
| `search`              | `SearchController`    | StudlyCase                         |
| `front-page`          | `FrontPageController` | StudlyCase                         |
| `archive-event`       | `EventsController`    | `archive-{type}` → pluralised type |
| `single-event`        | `EventController`     | `single-{type}` → the subject      |
| `taxonomy-event-type` | `EventTypeController` | `taxonomy-{tax}` → the subject     |

Parent framework controllers are never matched by convention — the parent theme's own templates already route to them — so dispatch only activates for controllers *you* define.

## Hyphenated Template Names

WordPress builds hierarchy candidates from your registered keys, so a taxonomy named `event_type` traditionally demands `taxonomy-event_type.php` — an underscore in a world of kebab-case filenames. With routing enabled, every underscored candidate gets a hyphenated twin, so `taxonomy-event-type.php` (and `taxonomy-event-type.twig`) work too. Underscored names keep working.

## The Config Map

Most themes need **no entries at all** — conventions cover the common cases. Use `config/controllers.php` only when a controller's name defies convention:

{% code title="config/controllers.php" lineNumbers="true" %}

```php
return [
    'archive-event' => \MyTheme\Controllers\WhatsOnController::class,
];
```

{% endcode %}

An explicit map entry always beats convention for its candidate.

## File-less Page Templates

Page templates traditionally require physical files for WordPress to discover their `Template Name:` headers. With routing enabled, register them declaratively instead — no `page-templates/` directory:

{% code title="config/page-templates.php" lineNumbers="true" %}

```php
return [
    'page-templates/contact-page.php' => 'Contact Page',
    'page-templates/grid-page.php'    => 'Grid Page',
];
```

{% endcode %}

Each registered template resolves to its `{Slug}Controller` by convention (`sidebar-page` → `SidebarPageController`), falling back to the framework `PageController`, and renders `{slug}.twig`.

{% hint style="info" %}
**Migrating an existing theme?** Use the legacy file-shaped ids shown above — they match the `_wp_page_template` values already stored on your pages, so assignments and the admin dropdown carry over with **no data migration**. New themes can use bare slugs like `'contact-page'`.
{% endhint %}

## When to Keep a Template File

A stub is still the right tool when there's genuine logic in template selection — the file always wins over dispatch, so nothing fights you:

{% code title="single-hit.php" lineNumbers="true" %}

```php
use MyTheme\Controllers\HitController;
use MyTheme\Controllers\SidebarPageController;

global $post;

if ( $post->post_parent === 0 ) {
    PressGang\PressGang::render( controller: HitController::class, twig: 'single-hit.twig' );
} else {
    PressGang\PressGang::render( controller: SidebarPageController::class, twig: 'sidebar-page.twig' );
}
```

{% endcode %}

## Custom Route Handlers

`config/routes.php` maps custom URL patterns (via the Upstatement Routes library) to a template filename — or, for routes that need logic before rendering, to a class implementing `RouteHandlerInterface`:

{% code title="config/routes.php" lineNumbers="true" %}

```php
return [
    'archive/:year'     => 'archive-year.php',
    'route/:slug/news/' => \MyTheme\Routes\NewsRoute::class,
];
```

{% endcode %}

A handler receives the matched route parameters and is responsible for loading a response — typically by building query args (Quartermaster's `toArgs()` is made for this) and handing off to the dispatcher:

{% code title="src/Routes/NewsRoute.php" lineNumbers="true" %}

```php
namespace MyTheme\Routes;

use PressGang\Quartermaster\Quartermaster;
use PressGang\Routes\RouteHandlerInterface;
use PressGang\Templates\TemplateHierarchy;

class NewsRoute implements RouteHandlerInterface {

    public function handle( array $params ): void {

        // This route renders the news listing regardless of what WP's
        // conditionals make of the query (empty paged listings flag 404).
        TemplateHierarchy::prepend( 'taxonomy-event-type' );

        \Routes::load(
            'dispatch.php',
            [ 'slug' => $params['slug'] ?? null ],
            Quartermaster::posts( 'post' )
                ->paged( (int) get_option( 'posts_per_page' ), (int) ( $params['paged'] ?? 1 ) )
                ->toArgs()
        );
    }
}
```

{% endcode %}

`TemplateHierarchy::prepend()` seeds the candidate your route *means*, so controller resolution stays deterministic even when WordPress's conditionals disagree (an empty page 2, for example).

## Precedence, In One Breath

**Template file → config map → naming convention.** Explicit always beats implicit, and your child theme always beats the framework.

### Controller template mappings

Use a parent controller when a view needs no additional context. In `config/controllers.php`, map a hierarchy candidate to a class string or `[ 'controller' => PageController::class, 'template' => 'page/research-subpage.twig' ]` (with the appropriate imported or fully qualified controller class). The explicit Twig path takes precedence over candidate discovery. Omitting `template`, or setting it to null, retains candidate discovery and constructor defaults. Candidate specificity and physical child PHP template precedence are unchanged. Keep custom controllers for additional getters, traits or behaviour. Before removing a class, check its class-derived rendering filters and action consumers; reusing the parent changes those hook names. This does not accept arbitrary constructor arguments.


# Context Managers

Context managers enrich the global `Timber::context()` with shared data that's available in every template. They're the quartermaster's store — making sure every template has the supplies it needs before setting sail.

## How They Work

Context managers implement the `ContextManagerInterface` and are registered in `config/context-managers.php`. During boot, the `TimberServiceProvider` instantiates each one and hooks them into the `timber/context` filter.

Every time `Timber::context()` is called (typically once per request, in a controller's constructor), each registered context manager has a chance to add its data.

### The Interface

{% code title="src/ContextManagers/ContextManagerInterface.php" %}

```php
namespace PressGang\ContextManagers;

interface ContextManagerInterface {
    /**
     * @param array<string, mixed> $context
     * @return array<string, mixed>
     */
    public function add_to_context(array $context): array;
}
```

{% endcode %}

## Built-in Context Managers

PressGang ships with five context managers out of the box:

### SiteContextManager

Adds the Timber `Site` object and a cache-busted stylesheet URL to the context.

**Context keys:** `site`, `site.stylesheet`

{% code title="views/layout.twig" %}

```twig
<link rel="stylesheet" href="{{ site.stylesheet }}">
<h1>{{ site.name }}</h1>
```

{% endcode %}

The stylesheet URL is filterable via `pressgang_stylesheet`.

### MenuContextManager

Adds all registered WordPress navigation menus as Timber `Menu` objects, keyed by location.

**Context keys:** `menu_{location}` (e.g. `menu_primary`, `menu_footer`)

{% code title="views/partials/nav.twig" %}

```twig
{% for item in menu_primary.items %}
    <a href="{{ item.link }}">{{ item.title }}</a>
{% endfor %}
```

{% endcode %}

Each menu is filterable via `pressgang_context_menu_{location}`.

### ThemeModsContextManager

Adds all WordPress Customizer theme modifications to the `theme` object.

**Context keys:** properties on `theme` (e.g. `theme.header_image`, `theme.custom_logo`)

{% code title="views/partials/header.twig" %}

```twig
{% if theme.custom_logo %}
    <img src="{{ theme.custom_logo }}" alt="{{ site.name }}">
{% endif %}
```

{% endcode %}

Each value is filterable via `pressgang_theme_mod_{key}`.

### AcfOptionsContextManager

Adds ACF (Advanced Custom Fields) options page fields to the context, converting values to Timber objects where appropriate. Results are cached via `wp_cache`.

**Context key:** `options`

{% code title="views/partials/footer.twig" %}

```twig
{{ options.company_name }}
{{ options.logo.src }}
```

{% endcode %}

{% hint style="info" %}
This manager only runs when ACF is active and `config/acf-options.php` is configured.
{% endhint %}

### WooCommerceContextManager

Adds WooCommerce-specific data to the context when WooCommerce is active.

## Creating a Custom Context Manager

{% stepper %}
{% step %}

#### Create the class

{% code title="src/ContextManagers/SocialLinksContextManager.php" lineNumbers="true" %}

```php
namespace MyTheme\ContextManagers;

use PressGang\ContextManagers\ContextManagerInterface;

class SocialLinksContextManager implements ContextManagerInterface {

    public function add_to_context(array $context): array {
        $context['social_links'] = [
            'twitter'  => get_option('social_twitter'),
            'facebook' => get_option('social_facebook'),
            'instagram' => get_option('social_instagram'),
        ];

        return $context;
    }
}
```

{% endcode %}
{% endstep %}

{% step %}

#### Register in config

Add it to your child theme's `config/context-managers.php`:

{% code title="config/context-managers.php" %}

```php
return [
    \PressGang\ContextManagers\SiteContextManager::class,
    \PressGang\ContextManagers\MenuContextManager::class,
    \PressGang\ContextManagers\ThemeModsContextManager::class,
    \PressGang\ContextManagers\AcfOptionsContextManager::class,
    \MyTheme\ContextManagers\SocialLinksContextManager::class,
];
```

{% endcode %}
{% endstep %}

{% step %}

#### Use in Twig

{% code title="views/partials/social.twig" %}

```twig
<a href="{{ social_links.twitter }}">Twitter</a>
```

{% endcode %}
{% endstep %}
{% endstepper %}

## Important Guidelines

{% hint style="danger" %}
Context managers run on **every request** — frontend, admin, AJAX, and CLI. Keep them lightweight!
{% endhint %}

* **Cache non-trivial queries.** If your context manager fetches data from the database, wrap it in `wp_cache_get()`/`wp_cache_set()`.
* **Only add data needed across many templates.** Data specific to a single page should live in the controller, not a context manager.
* **Keep it side-effect free.** Context managers must not write to the database, send emails, or perform remote requests.
* **Don't overwrite built-in Timber context keys** like `site`, `request`, or `user`.


# ACF Values

Use Timber's existing bridge for presentation fields, per call or through the opt-in setting in config/timber.php. PressGang adds no replacement bridge. Existing themes need no changes on upgrade. ACF remains optional; these ACF examples require it, while ordinary Timber metadata access still works without it.

```php
$rows = $post->meta( 'flexible_content', [ 'transform_value' => true ] );
```

ACF recursively invokes Timber's replacement formatters, including inside groups, repeaters and flexible content. Arrays retain their row/layout structure; object leaves use the existing Timber class maps. Opt in the whole root only when its consumers expect the transformed types.

## Return types and boundaries

Verified against Timber 2.5.1 and ACF 6.8.9:

| ACF type                          | Nonempty transformed value                             |
| --------------------------------- | ------------------------------------------------------ |
| `post_object`                     | Timber Post, or PostArrayObject when multiple          |
| `relationship`                    | Timber PostArrayObject                                 |
| `image`                           | Timber Image                                           |
| `file`                            | Timber Attachment (or attachment subclass)             |
| `gallery`                         | PostArrayObject containing attachment/image posts      |
| `taxonomy`                        | Timber Term for select/radio; array of terms otherwise |
| `user`                            | Timber User, or array of users when multiple           |
| `date_picker`, `date_time_picker` | DateTimeImmutable                                      |

Object-like fields generally return `false` when empty. Collections are not native PHP arrays. Check empty/deleted selections and callers' return types when migrating; do not assume exact parity with the legacy mapper.

ACF's standard taxonomy type is `taxonomy`, not `term`. No custom `term` type was registered in the audited penarc site. The mapper's historical `term` case remains unchanged for other consumers.

The switch applies to Timber metadata access, not ordinary `get_field()`, `get_sub_field()` or options-page reads. Options and block contexts still use `TimberMapper`. The options cache remains `theme_option_field_objects` in group `pressgang`, invalidated on `acf/save_post`. It holds ACF-formatted, Timber-unmapped field objects; it is not an unformatted-ID cache. Nested options remain unchanged.

## Known upstream limitations

Use **one formatted mode per field/entity per request**, across PHP, Twig and third-party ACF reads. Include nested sub-fields in that audit.

ACF 6.8.9 shares its formatted-value cache between normal ACF and transformed reads. In live probes, a normal nested-image read followed by a transformed read returned an ACF image array both times. Reversing the order returned a Timber Image both times. `transform_value => false` alone does not undo a transformed read. Normal WP\_Post arrays may still become arrays of Timber posts through Timber's fallback conversion, so successful relationship conversion does not prove that all nested field types escaped the cache issue.

Timber 2.5.1 also lacks exception-safe restoration of its temporary ACF filters. A throwing formatter left the original ACF image formatter removed and Timber's replacement installed. Catching the exception does not make subsequent ACF reads safe. Avoid reentrant transformed reads inside formatting callbacks; PressGang does not provide scope management or vendor patches.

Keep the existing explicit mapper when a request needs normal ACF arrays/date strings as well as Timber objects. These version-specific limitations should be rechecked on dependency upgrades; this change does not fix them upstream.

## Opting out and reading IDs

Transformation remains off by default. Opt in or out for a selected root at its `meta()` call, or enable `transform_acf_values` after auditing the child theme: query IDs, date strings and image/file arrays have existing consumers. There is no per-type or nested-path switch in this policy. Keep a mixed-use root untransformed and use explicit mapping where necessary.

For normal ACF formatting, use ordinary `meta()` or `transform_value => false` consistently from the start of the request. For IDs/storage-oriented input that must coexist with transformed reads, use one of these:

```php
// Unformatted ACF values: disable BOTH flags.
$ids = $post->meta( 'related_events', [
    'transform_value' => false,
    'format_value'    => false,
] );

// Native WordPress storage, bypassing ACF.
$ids = $post->raw_meta( 'related_events' );
```

`format_value => false` alone loses to an enabled transform: Timber calls `get_field(..., true)` in that path. Storage values are not normal ACF-formatted values: a raw repeater root can be a row count, and unformatted ACF sub-fields can be keyed by field keys. Do not substitute either escape into a template expecting image arrays or formatted dates.

Set `transform_acf_values` to false to return to the parent default. Remove any local global-transform filter registration too; the setting does not override it.

## Worked penarc migrations

These are opt-in edits for a consuming theme, not changes applied to penarc by this parent-theme documentation update. Audit all reads of each selected field first. In the inspected source, these four fields feed presentation getters; query builders using their names compare the current post ID rather than reading these field values. Request-wide plugin behaviour still needs a rendered-page check.

The existing shape is:

```php
return TimberMapper::to_timber_posts( $this->get_post()->meta( 'staff_authors' ) );
```

These replacements preserve the getters' native-array contracts. Timber performs the conversion; the remaining normalization materializes collections, removes null members for missing targets, and reindexes the result. This matters in the actual dataset: post 20166's `other_arc_staff` collection contains a null member. `?: []` handles empty fields, and the array branch also accepts Timber's fallback conversion of an already formatted WP\_Post array. That branch does not fix the cache problem for other field types.

`src/Traits/HasAuthors.php`:

```php
/**
 * Returns staff authors.
 *
 * @return array<int, \PenArc\Models\StaffMember>
 */
protected function get_staff_authors(): array {
    $posts = $this->get_post()->meta( 'staff_authors', [ 'transform_value' => true ] ) ?: [];
    $posts = is_array( $posts ) ? $posts : iterator_to_array( $posts );

    return array_values( array_filter( $posts ) );
}
```

`src/Traits/HasArticleRelationships.php`:

```php
/**
 * Returns contact staff members.
 *
 * @return array<int, \Timber\Post>
 */
protected function get_contact_staff_members(): array {
    $posts = $this->get_post()->meta( 'contact_staff_members', [ 'transform_value' => true ] ) ?: [];
    $posts = is_array( $posts ) ? $posts : iterator_to_array( $posts );

    return array_values( array_filter( $posts ) );
}
```

`src/Controllers/ResearchProjectController.php`:

```php
/**
 * Returns other ARC staff.
 *
 * @return array<int, \Timber\Post>
 */
protected function get_other_arc_staff(): array {
    $posts = $this->get_post()->meta( 'other_arc_staff', [ 'transform_value' => true ] ) ?: [];
    $posts = is_array( $posts ) ? $posts : iterator_to_array( $posts );

    return array_values( array_filter( $posts ) );
}

/**
 * Returns ARC lead.
 *
 * @return array<int, \Timber\Post>
 */
protected function get_arc_lead(): array {
    $posts = $this->get_post()->meta( 'arc_lead', [ 'transform_value' => true ] ) ?: [];
    $posts = is_array( $posts ) ? $posts : iterator_to_array( $posts );

    return array_values( array_filter( $posts ) );
}
```

Remove the unused `use PressGang\ACF\TimberMapper;` import from each edited file. Do not pass the transformed collection back into `to_timber_posts()`: that helper accepts native arrays and otherwise returns an empty array. It remains supported, with its existing filtering and reindexing behaviour, for unmigrated consumers.

These examples were compared with the existing helper on live selections: `staff_authors` on post 23295, `contact_staff_members` on 10460, `other_arc_staff` on 20166 and `arc_lead` on 20169. All four preserved the selected IDs and order after normalization. The comparison isolated the ACF value cache between modes; it does not demonstrate safe mixed-mode reads.

If callers accept collections and selections cannot contain missing targets, a direct transformed read with `?: []` and an `iterable` return type can omit normalization. That is a deliberate contract change, not the compatibility example above. Keeping the existing mapper is also a supported choice.

For an audited nested presentation root, Twig can use the same upstream argument:

```twig
{% set rows = post.meta('flexible_content', { transform_value: true }) %}
```

All supported descendants transform, including dates and files. Do not turn this on until the macros receiving those rows expect those types.

## Evidence and performance

Live WP-CLI probes on penarc (PHP 8.3.8, Timber 2.5.1, ACF 6.8.9) demonstrated:

| Read                                                     | Result                                           |
| -------------------------------------------------------- | ------------------------------------------------ |
| Post 17, `flexible_content[2].people`                    | PostArrayObject, 9 StaffMember objects           |
| Post 65513, `flexible_content[0].cards[0].image`         | Timber Image, ID 49660                           |
| Post 20184, `lead_collaborators[0].partner_organisation` | Timber Term, ID 3345                             |
| Post 66610, `themes`                                     | `false`; stored fixture is empty                 |
| Existing `themes` schema with an in-memory row           | PostArrayObject containing ResearchProject 20184 |
| In-memory group containing relationship/date             | PostArrayObject and DateTimeImmutable            |

Independent probes started with a fresh request-local ACF value store; the in-memory fixtures did not write content. Do not clear ACF's caches in application code as a workaround.

The database contained 1,524 published publications and 46 events. Three fields on 24 publications (72 calls) took a median 1.799 ms for warm normal ACF reads, versus 3.623 ms transformed. Across all publications (4,572 calls), warm times were 118.715 ms versus 225.346 ms. Both warm modes issued zero additional SQL. Timber performs 9 field-type lookups and 36 filter mutation calls for each transformed read, including scalar fields and cache hits.

A cold source-post read of a 27-item relationship used 2 queries with normal ACF versus 28 transformed; iterating the transformed collection added 6 queries. The numeric-ID path eagerly creates posts. Source-post metadata priming alone does not eliminate related-object N+1 queries. Transform only fields needed for the displayed page and measure rendered listings, not just warmed CLI loops.

See [Timber's ACF documentation](https://timber.github.io/docs/v2/integrations/advanced-custom-fields/) for the upstream API. The runtime findings above qualify its per-call opt-out advice for the versions tested.

## Configuring an audited child theme

Keep existing Twig settings when adding this option to `config/timber.php` (child configuration replaces the parent file):

```php
return [
    'transform_acf_values' => true,
];
```

The setting defaults to false. Replace a local `TransformAcfValues` snippet and its registration with this configuration. Call `PressGang\Bootstrap\Config::clear_cache()` after changing configuration. Transformation uses Timber's existing bridge and class maps; ACF remains optional. Hook registration is harmless without ACF and no ACF functions are called by this configuration handler.

Missing references may still produce null collection entries. Keep presentation checks for missing values; this option does not add filtering or repair data.

Use raw\_meta() for stored query IDs. Setting transform\_value to false per call opts out of Timber transformation, but ACF's shared formatted cache still applies as described above. Disable the configuration option to stop registering its policy; false does not override filters installed by another plugin or theme.


# Twig Extensions

Twig extensions let you add custom functions, filters, and globals to the Twig templating environment. They're the rigging of your PressGang ship — connecting the PHP engine room to the Twig deck where templates do their work.

## How They Work

All Twig extensions are managed through extension manager classes that implement the `TwigExtensionManagerInterface`. These managers are registered in `config/twig-extensions.php` and wired up during boot by the `TimberServiceProvider`.

### The Interface

{% code title="src/TwigExtensions/TwigExtensionManagerInterface.php" %}

```php
namespace PressGang\TwigExtensions;

use Twig\Environment;

interface TwigExtensionManagerInterface {
    public function add_twig_functions(Environment $twig): void;
    public function add_twig_filters(Environment $twig): void;
    public function add_twig_globals(Environment $twig): void;
}
```

{% endcode %}

Each method receives the Twig `Environment` and can register any number of functions, filters, or globals.

### Convenience Traits

If your extension only needs to implement one or two of the three methods, PressGang provides no-op traits to keep your code clean:

* `HasNoFunctions` — provides an empty `add_twig_functions()`.
* `HasNoFilters` — provides an empty `add_twig_filters()`.
* `HasNoGlobals` — provides an empty `add_twig_globals()`.

## Built-in Extension Managers

### GeneralExtensionManager

Registers general-purpose functions and globals:

**Functions:**

* `get_search_query()` — returns the current search query string.
* `get_option(name)` — retrieves a WordPress option.
* `get_theme_mod(name)` — retrieves a theme modification value.

**Globals:**

* `THEMENAME` — the text domain constant, for use in translation calls.

{% code title="views/search-form.twig" %}

```twig
<form action="/">
    <input type="search" value="{{ get_search_query() }}">
</form>

<p>{{ __('Welcome aboard!', THEMENAME) }}</p>
```

{% endcode %}

### MetaDescriptionExtensionManager

**Functions:**

* `meta_description()` — generates an SEO-friendly meta description for the current page, via the `MetaDescriptionService`.

{% code title="views/layouts/base.twig" %}

```twig
<meta name="description" content="{{ meta_description() }}">
```

{% endcode %}

See [SEO](/seo) for details on the meta description fallback chain.

### SinglePostExtensionManager

Only active on single post pages. Requires the post to be mapped to `PressGang\Post` via the `timber-class-map` config.

**Functions:**

* `get_latest_posts(count)` — fetches the latest posts (excluding the current one).
* `get_related_posts(count)` — fetches posts related to the current one by shared taxonomy terms.

{% code title="views/partials/related-posts.twig" %}

```twig
{% for post in get_related_posts(3) %}
    <a href="{{ post.link }}">{{ post.title }}</a>
{% endfor %}
```

{% endcode %}

### WidgetExtensionManager

Registers a Twig function for rendering WordPress widgets in templates.

### WooCommerceExtensionManager

Registers WooCommerce-specific Twig functions when WooCommerce is active.

## Creating a Custom Extension Manager

{% stepper %}
{% step %}

#### Create the class

{% code title="src/TwigExtensions/SocialExtensionManager.php" lineNumbers="true" %}

```php
namespace MyTheme\TwigExtensions;

use PressGang\TwigExtensions\HasNoFilters;
use PressGang\TwigExtensions\HasNoGlobals;
use PressGang\TwigExtensions\TwigExtensionManagerInterface;
use Twig\Environment;
use Twig\TwigFunction;

class SocialExtensionManager implements TwigExtensionManagerInterface {

    use HasNoFilters;
    use HasNoGlobals;

    public function add_twig_functions(Environment $twig): void {
        $twig->addFunction(new TwigFunction('share_url', function (string $platform, string $url): string {
            return match ($platform) {
                'twitter'  => "https://twitter.com/intent/tweet?url=" . urlencode($url),
                'facebook' => "https://www.facebook.com/sharer/sharer.php?u=" . urlencode($url),
                default    => $url,
            };
        }));
    }
}
```

{% endcode %}
{% endstep %}

{% step %}

#### Register in config

Add to your child theme's `config/twig-extensions.php`:

{% code title="config/twig-extensions.php" %}

```php
return [
    \PressGang\TwigExtensions\GeneralExtensionManager::class,
    \PressGang\TwigExtensions\MetaDescriptionExtensionManager::class,
    \PressGang\TwigExtensions\SinglePostExtensionManager::class,
    \PressGang\TwigExtensions\WidgetExtensionManager::class,
    \MyTheme\TwigExtensions\SocialExtensionManager::class,
];
```

{% endcode %}
{% endstep %}

{% step %}

#### Use in Twig

{% code title="views/partials/share-buttons.twig" %}

```twig
<a href="{{ share_url('twitter', post.link) }}">Share on Twitter</a>
```

{% endcode %}
{% endstep %}
{% endstepper %}

## Rules for Twig Functions

{% hint style="danger" %}
Twig is for presentation only. Keep your Twig functions pure and side-effect free!
{% endhint %}

* **No database queries** — if you need data, provide it via a controller or context manager.
* **No writes** — no `update_option()`, `wp_insert_post()`, or similar.
* **No remote requests** — no `wp_remote_get()` or API calls.
* **Deterministic** — same inputs should always produce the same outputs.
* **Pure** — no side effects, no mutation of global state.

{% hint style="info" %}
The one documented exception is `WooCommerceExtensionManager::timber_set_product()`, which sets `global $product` as required by WooCommerce's template system.
{% endhint %}


# Service Providers

Service providers are PressGang's hook for bootstrapping services after the framework has initialised. They run after Timber and the Loader are ready, making them the right place for wiring up filters, registering integrations, or initialising third-party libraries.

PressGang ships with one service provider by default — `TimberServiceProvider` — which registers context managers, Twig extensions, Twig environment options, and snippet template paths. The framework also provides the opt-in `TemplateRoutingServiceProvider` for convention-based [Template Routing](/template-routing). And you can add your own alongside them.

## How They Work

```mermaid
graph LR
    A["config/service-providers.php"] --> B["pressgang_service_providers filter"]
    B --> C["PressGang instantiates each class"]
    C --> D["boot() called on each provider"]
```

{% stepper %}
{% step %}

### 1. Config declares providers

Class strings are listed in `config/service-providers.php`. Child theme config **replaces** the parent file, so always include `TimberServiceProvider` unless you're intentionally removing it.
{% endstep %}

{% step %}

### 2. Filter allows modification

The list is passed through the `pressgang_service_providers` filter, letting plugins or mu-plugins add or remove providers.
{% endstep %}

{% step %}

### 3. PressGang boots each one

Each class string is validated: it must be a loadable class implementing `ServiceProviderInterface`. Invalid entries are skipped silently — no fatal errors from a bad config line.
{% endstep %}
{% endstepper %}

## The Interface

Every service provider implements `ServiceProviderInterface`, which requires exactly one method:

{% code title="src/ServiceProviders/ServiceProviderInterface.php" %}

```php
namespace PressGang\ServiceProviders;

interface ServiceProviderInterface {
    public function boot(): void;
}
```

{% endcode %}

{% hint style="info" %}
Providers are instantiated with **no constructor arguments**. If your provider needs configuration, read it from `Config::get()` or WordPress options inside `boot()`.
{% endhint %}

## Default Configuration

{% code title="config/service-providers.php" lineNumbers="true" %}

```php
return [
    \PressGang\ServiceProviders\TimberServiceProvider::class,
];
```

{% endcode %}

`TimberServiceProvider` wires up:

| Concern                  | Config source                 | Hook                              |
| ------------------------ | ----------------------------- | --------------------------------- |
| Context managers         | `config/context-managers.php` | `timber/context`                  |
| Twig extensions          | `config/twig-extensions.php`  | `timber/twig`                     |
| Twig environment options | `config/timber.php`           | `timber/twig/environment/options` |
| Snippet template paths   | vendor directory              | `timber/locations`                |

See [Context Managers](/context-managers) and [Twig Extensions](/twig-extensions) for details on each.

## Writing a Custom Service Provider

{% stepper %}
{% step %}

### Create the class

{% code title="src/ServiceProviders/SearchServiceProvider.php" lineNumbers="true" %}

```php
namespace MyTheme\ServiceProviders;

use PressGang\ServiceProviders\ServiceProviderInterface;

/**
 * Customises the main search query to exclude specific post types
 * and boost exact title matches.
 */
class SearchServiceProvider implements ServiceProviderInterface {

    public function boot(): void {
        \add_action('pre_get_posts', [$this, 'customise_search']);
    }

    public function customise_search(\WP_Query $query): void {
        if (! $query->is_main_query() || ! $query->is_search() || \is_admin()) {
            return;
        }

        $query->set('post_type', ['post', 'page', 'event']);
    }
}
```

{% endcode %}
{% endstep %}

{% step %}

### Register in config

{% code title="config/service-providers.php" lineNumbers="true" %}

```php
return [
    \PressGang\ServiceProviders\TimberServiceProvider::class,
    \MyTheme\ServiceProviders\SearchServiceProvider::class,
];
```

{% endcode %}

{% hint style="warning" %}
A child theme's `config/service-providers.php` **replaces** the parent file entirely. Always include `TimberServiceProvider` unless you are intentionally removing PressGang's default Timber integration.
{% endhint %}
{% endstep %}
{% endstepper %}

## Adding a Provider via Filter

Plugins and mu-plugins can add providers without touching config files:

{% code title="Plugin or mu-plugin" lineNumbers="true" %}

```php
add_filter('pressgang_service_providers', function (array $providers): array {
    $providers[] = \MyPlugin\ServiceProviders\AnalyticsServiceProvider::class;
    return $providers;
});
```

{% endcode %}

## Guidelines

{% hint style="danger" %}
Service providers boot on **every request**. Keep `boot()` lightweight — register hooks and filters only, don't do real work.
{% endhint %}

* **Register hooks in `boot()`, execute work in callbacks.** The `boot()` method should only call `add_action()` / `add_filter()` — not perform queries, remote requests, or heavy computation.
* **One concern per provider.** If your provider handles both search customisation and email configuration, split it into two providers.
* **Guard for dependencies.** If your provider depends on a plugin (ACF, WooCommerce, etc.), check `class_exists()` or `function_exists()` before registering hooks.
* **No constructor arguments.** PressGang instantiates providers with `new $class()`. Use `Config::get()` or WordPress options for configuration.

## Hooks

| Hook                          | Type   | Purpose                                                       |
| ----------------------------- | ------ | ------------------------------------------------------------- |
| `pressgang_service_providers` | filter | Modify the list of service provider class strings before boot |


# Blocks

PressGang provides declarative Gutenberg block registration, keeping your block setup clean and your codebase shipshape. Define your blocks in config, and PressGang handles the registration, path resolution, and lifecycle hooks.

## How It Works

Blocks are registered via `config/blocks.php`. Each entry points to a directory containing a `block.json` file. PressGang's `Blocks` configuration class:

1. Resolves the block path (checking the child theme first, then the parent theme).
2. Validates and reads the `block.json` file.
3. Registers the block type with WordPress.
4. Invokes any `on_register` callback defined in the block's ACF `renderCallback`.
5. Fires a `pressgang_block_registered_{name}` action for additional setup.

## Directory Structure

Each block lives in its own directory under `blocks/`:

{% code title="blocks/" %}

```
blocks/
  hero/
    block.json
    hero.twig
  testimonial/
    block.json
    testimonial.twig
```

{% endcode %}

The `block.json` file follows the standard [WordPress block.json](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-metadata/) format.

## Configuration

{% tabs %}
{% tab title="Simple Registration" %}
List paths to block directories in `config/blocks.php`:

{% code title="config/blocks.php" %}

```php
return [
    '/blocks/hero',
    '/blocks/testimonial',
    '/blocks/call-to-action',
];
```

{% endcode %}
{% endtab %}

{% tab title="With Additional Arguments" %}
Pass extra arguments for `register_block_type()` using an associative array:

{% code title="config/blocks.php" %}

```php
return [
    '/blocks/hero' => [
        'style' => 'hero-styles',
    ],
    '/blocks/testimonial',
];
```

{% endcode %}
{% endtab %}
{% endtabs %}

## Child/Parent Theme Resolution

PressGang automatically checks the child theme directory first when resolving block paths. This means you can override a parent theme's block by creating a block with the same path in your child theme. The resolved paths are cached for performance.

{% code title="Resolution order" %}

```
1. Check: get_stylesheet_directory() . '/blocks/hero'  (child theme)
2. Fallback: get_template_directory() . '/blocks/hero'  (parent theme)
```

{% endcode %}

## ACF Blocks

PressGang has first-class support for [ACF blocks](https://www.advancedcustomfields.com/resources/blocks/). When a block's `block.json` defines an ACF `renderCallback`, PressGang will:

1. Look for the class specified in `acf.renderCallback[0]`.
2. If the class exists and has a static `on_register()` method, call it with the block settings.

This allows blocks to perform one-time setup (like registering field groups) at registration time.

{% code title="blocks/hero/block.json" %}

```json
{
    "name": "acf/hero",
    "title": "Hero",
    "description": "A hero banner block.",
    "category": "theme",
    "acf": {
        "mode": "preview",
        "renderCallback": ["App\\Blocks\\Hero", "render"]
    }
}
```

{% endcode %}

## Hooks

| Hook                                | Type   | Purpose                                                               |
| ----------------------------------- | ------ | --------------------------------------------------------------------- |
| `pressgang_block_registered_{name}` | action | Fired after a block is registered. Receives the block settings array. |

The `{name}` placeholder is the block's `name` field from `block.json` (e.g. `pressgang_block_registered_acf/hero`).

## Block Rendering Rules

{% hint style="success" %}
Block rendering should be a pure function of block context — no side effects, no queries in the render path that aren't cached.
{% endhint %}

* Keep block templates simple and focused.
* Prefer Twig templates over inline PHP render callbacks.
* For new UI elements, prefer blocks over shortcodes — they give editors a much better experience.


# Snippets

Snippets are PressGang's answer to the WordPress `functions.php` junk drawer. Instead of dumping unrelated functionality into a single file — analytics scripts next to image size definitions next to admin tweaks — each concern gets its own class, with its own configuration, that can be enabled or disabled with a single line.

Think of snippets as your ship's provisions — pre-packaged, self-contained, ready to be loaded aboard any theme.

## Why Snippets Instead of `functions.php`

In a typical WordPress theme, `functions.php` grows into a sprawling file that mixes unrelated concerns: analytics tracking, custom image sizes, admin tweaks, WooCommerce overrides, Customizer settings. This creates problems:

* **Hard to find things.** Where's the code that disables emojis? Somewhere in 800 lines.
* **Hard to reuse.** Want the same analytics setup on another site? Copy-paste and hope you got everything.
* **Hard to disable.** Commenting out blocks of code is error-prone and messy.
* **Hard to share.** Distributing a `functions.php` snippet via Composer isn't practical.

Snippets solve all of these:

| `functions.php` approach          | Snippet approach                                |
| --------------------------------- | ----------------------------------------------- |
| One file, many concerns           | One class per concern                           |
| Enable/disable by commenting code | Enable/disable by adding/removing a config line |
| Copy-paste between projects       | Install via Composer, share across all themes   |
| Arguments buried in code          | Configuration passed explicitly via `$args`     |
| No standard structure             | Every snippet implements the same interface     |
| Grows without limit               | Each snippet stays small and focused            |

## How Snippets Work

### The Interface

Every snippet implements `SnippetInterface`, which requires exactly one thing — a constructor that accepts an array of arguments:

{% code title="src/Snippets/SnippetInterface.php" %}

```php
namespace PressGang\Snippets;

interface SnippetInterface {
    public function __construct(array $args);
}
```

{% endcode %}

The constructor is where the snippet registers its WordPress hooks. Once constructed, the snippet is fully operational — no additional calls needed.

### The Config File

Snippets are activated in your theme's `config/snippets.php`. Each entry maps a snippet class to its arguments:

{% code title="config/snippets.php" lineNumbers="true" %}

```php
return [
    // No configuration needed — pass an empty array
    'PressGang\\Snippets\\DisableEmojis' => [],

    // Configuration passed via the args array
    'PressGang\\Snippets\\ImageSizes' => [
        'thumbnail' => ['width' => 150, 'height' => 150, 'crop' => true],
        'hero'      => ['width' => 1920, 'height' => 600, 'crop' => true],
        'hero'      => false,  // Disable a size
    ],

    // Customizer-based snippets configure themselves via the WP Customizer
    'PressGang\\Snippets\\GoogleAnalytics' => [],
];
```

{% endcode %}

To disable a snippet, remove or comment out its line. To reconfigure one, change its `$args` array. No code changes needed.

### Namespace Resolution

PressGang resolves snippet class names in this order:

1. **Fully qualified** — if the name starts with `PressGang\` or your child theme namespace, it's used directly.
2. **Child theme first** — `YourTheme\Snippets\SnippetName` is checked, allowing you to override a library snippet with your own version.
3. **Parent fallback** — `PressGang\Snippets\SnippetName` is used if no child theme override exists.

This means you can reference snippets by short name when they live in a standard namespace:

{% code title="config/snippets.php" %}

```php
return [
    // These are equivalent:
    'PressGang\\Snippets\\DisableEmojis' => [],
    'DisableEmojis' => [],

    // Subfolders work too:
    'WooCommerce\\ProductColorSwatch' => [],
];
```

{% endcode %}

### Template Paths

PressGang automatically adds the `pressgang-snippets` vendor views directory to Timber's template lookup paths. Twig templates bundled with snippet packages are available to your theme without any manual path configuration.

## Anatomy of a Snippet

Here's what a typical snippet looks like — this one adds Google Analytics tracking via the WordPress Customizer:

{% code title="src/Snippets/GoogleAnalytics.php" lineNumbers="true" %}

```php
namespace PressGang\Snippets;

use Timber\Timber;

/**
 * Injects a Google Analytics (gtag.js) tracking script into wp_head. The tracking
 * ID is managed via the WordPress Customizer under a "Google" section. An optional
 * toggle controls whether logged-in users are tracked.
 *
 * No $args configuration needed — the tracking ID is entered via the Customizer.
 */
class GoogleAnalytics implements SnippetInterface {

    public function __construct(array $args) {
        \add_action('customize_register', [$this, 'add_to_customizer']);
        \add_action('wp_head', [$this, 'script']);
    }

    public function add_to_customizer(\WP_Customize_Manager $wp_customize): void {
        // Add Customizer section, setting, and control...
    }

    public function script(): void {
        if ($google_analytics_id = \get_theme_mod('google-analytics-id')) {
            Timber::render('snippets/google-analytics.twig', [
                'google_analytics_id' => $google_analytics_id,
            ]);
        }
    }
}
```

{% endcode %}

Key things to notice:

* **Constructor registers hooks** — `customize_register` for the Customizer UI, `wp_head` for the script output.
* **No work in the constructor itself** — it only wires up hooks for WordPress to call later.
* **Renders via Timber** — output goes through a Twig template, not inline `echo` statements.
* **Guards its output** — checks that a tracking ID exists before rendering anything.

## Writing Your Own Snippets

Child themes will often need site-specific snippets that don't belong in the shared library. This is expected and encouraged — it's far better to write a snippet class than to add code to `functions.php`.

{% stepper %}
{% step %}

### Create the class

Place it in your child theme's `src/Snippets/` directory:

{% code title="src/Snippets/AdminBarQuoteButton.php" lineNumbers="true" %}

```php
namespace YourTheme\Snippets;

use PressGang\Snippets\SnippetInterface;

/**
 * Adds a "Request a Quote" button to the admin bar on product pages.
 *
 * The button links to a configurable URL passed via $args. Only appears for
 * users with the 'edit_posts' capability.
 */
class AdminBarQuoteButton implements SnippetInterface {

    private string $url;

    public function __construct(array $args) {
        $this->url = $args['url'] ?? '/contact';
        \add_action('admin_bar_menu', [$this, 'add_button'], 100);
    }

    public function add_button(\WP_Admin_Bar $admin_bar): void {
        if (!\current_user_can('edit_posts') || !\is_singular('product')) {
            return;
        }

        $admin_bar->add_node([
            'id'    => 'request-quote',
            'title' => \__('Request a Quote', THEMENAME),
            'href'  => \esc_url($this->url),
        ]);
    }
}
```

{% endcode %}
{% endstep %}

{% step %}

### Register in config

{% code title="config/snippets.php" %}

```php
return [
    'AdminBarQuoteButton' => ['url' => '/request-quote'],
];
```

{% endcode %}

Because your child theme namespace is checked first, you only need the short class name.
{% endstep %}

{% step %}

### Add a Twig template (if needed)

If your snippet renders output, place the template in your child theme's `views/snippets/` directory. Render it via `Timber::render('snippets/your-template.twig', $context)`.
{% endstep %}
{% endstepper %}

## Common Snippet Patterns

<details>

<summary><strong>Customizer + Render</strong></summary>

Adds a setting to the WordPress Customizer and renders output based on that setting. Used for third-party scripts, tracking pixels, and theme options that need a simple admin UI.

{% code title="Pattern" %}

```php
public function __construct(array $args) {
    \add_action('customize_register', [$this, 'add_to_customizer']);
    \add_action('wp_head', [$this, 'render']);
}
```

{% endcode %}

**Examples:** `GoogleAnalytics`, `GoogleTagManager`, `FacebookPixel`, `Hotjar`, `GoogleRecaptcha`

</details>

<details>

<summary><strong>Hook Filtering</strong></summary>

Modifies WordPress behaviour via actions and filters. No UI, no templates — just behavioural changes.

{% code title="Pattern" %}

```php
public function __construct(array $args) {
    $this->exclude = $args['exclude'] ?? [];
    \add_filter('pre_get_posts', [$this, 'modify_query']);
}
```

{% endcode %}

**Examples:** `DisableEmojis`, `BigImageScaling`, `SearchExcludePostTypes`, `RemovePosts`

</details>

<details>

<summary><strong>Config-Driven Registration</strong></summary>

Receives structured `$args` and registers WordPress resources. The args array shape mirrors WordPress API conventions.

{% code title="Pattern" %}

```php
public function __construct(array $args) {
    $this->args = $args;
    \add_action('init', [$this, 'setup_image_sizes']);
}
```

{% endcode %}

**Examples:** `ImageSizes`, `Permalinks`, `AddQueryVars`

</details>

<details>

<summary><strong>Admin Features</strong></summary>

Adds functionality to the WordPress admin — row actions, admin notices, editor customisation. Always includes capability checks and nonce verification.

**Examples:** `DuplicatePost`, `AdminLogo`, `TinyMceBlockFormats`

</details>

<details>

<summary><strong>Twig Function Registration</strong></summary>

Registers a callable function into the Twig environment, making it available in templates as `{{ function_name() }}`.

{% code title="Pattern" %}

```php
public function __construct(array $args) {
    \add_filter('timber/twig', [$this, 'add_to_twig']);
}

public function add_to_twig(Environment $twig): Environment {
    $twig->addFunction(new TwigFunction('breadcrumb', [$this, 'render']));
    return $twig;
}
```

{% endcode %}

**Examples:** `Breadcrumb`

</details>

## Guidelines

{% hint style="danger" %}
Snippets are constructed during theme setup and their hooks fire on every request. Keep constructors lightweight — register hooks only, don't do real work.
{% endhint %}

* **One concern per snippet.** If you're tempted to add unrelated functionality, create a second snippet.
* **Accept `array $args`** and document what keys are supported. Provide sensible defaults.
* **Guard for context.** Don't assume you're on the frontend — snippets may fire on admin, AJAX, or CLI requests. Check `\is_admin()`, `\is_singular()`, etc. where appropriate.
* **Guard for dependencies.** If a snippet depends on ACF or WooCommerce, check `function_exists()` or `class_exists()` before calling their APIs.
* **Escape output.** Use Twig auto-escaping for templates. Use `\esc_html()`, `\esc_attr()`, `\esc_url()` for PHP output.
* **Sanitise input.** Always apply `sanitize_text_field()`, `\absint()`, etc. to values from `$_GET`/`$_POST` or Customizer settings.
* **Use fully-qualified function calls.** Write `\add_action()`, not `add_action()`, in namespaced code.

## PressGang Snippets Library

A curated collection of 45+ ready-to-use snippets is available as a separate Composer package:

{% code title="Terminal" %}

```bash
composer require pressgang-wp/pressgang-snippets
```

{% endcode %}

This includes snippets for Google Analytics, Tag Manager, Facebook Pixel, emoji removal, image sizes, breadcrumbs, Open Graph tags, JSON-LD schemas, WooCommerce tweaks, block-style removal, SVG uploads, taxonomy admin UI removal, Timber menu-item class mapping, and more. See [pressgang-wp/pressgang-snippets](https://github.com/pressgang-wp/pressgang-snippets) for the full list.


# Forms and Validation

PressGang provides a structured form handling pipeline with built-in validation, CSRF protection, and error handling. No more tangled `$_POST` processing scattered across your theme — PressGang keeps your forms battened down and secure.

## Architecture

```mermaid
graph LR
    A["Form submit"] --> B["WordPress<br/>admin-post"]
    B --> C["FormSubmission::<br/>handle_form_submission()"]
    C --> D["Nonce verification"]
    D --> E["Input flashing"]
    E --> F["Validator pipeline"]
    F --> G["process_submission()<br/>(your logic)"]
    G --> H["Redirect with status"]
```

## FormSubmission (Base Class)

The abstract `FormSubmission` class handles the form lifecycle:

1. **Nonce verification** — rejects requests with invalid or missing nonces.
2. **Input flashing** — sanitises and stores submitted values in the session (via `Flash`), so forms can be repopulated after validation errors.
3. **Validation** — runs all configured validators and collects errors.
4. **Processing** — calls your `process_submission()` implementation on success.
5. **Redirect** — sends the user back to the referring page with success/error flags.

### Creating a Form Handler

Extend `FormSubmission` and implement `process_submission()`:

{% code title="src/Forms/NewsletterSubmission.php" lineNumbers="true" %}

```php
namespace MyTheme\Forms;

use PressGang\Forms\FormSubmission;

class NewsletterSubmission extends FormSubmission {

    protected function process_submission(): void {
        $email = sanitize_email($_POST['email'] ?? '');

        // Your newsletter subscription logic here
        subscribe_to_newsletter($email);
    }
}
```

{% endcode %}

### Initialising and Registering Hooks

Form handlers register themselves with WordPress via `admin_post` actions:

{% code title="Snippet or functions.php" %}

```php
use MyTheme\Forms\NewsletterSubmission;

NewsletterSubmission::init([
    'action' => 'newsletter_signup',
    'validators' => [
        new \PressGang\Forms\Validators\EmailValidator(['email']),
    ],
]);
```

{% endcode %}

This registers handlers for both logged-in (`admin_post_{action}`) and logged-out (`admin_post_nopriv_{action}`) users.

## Built-in: ContactSubmission

PressGang ships with a `ContactSubmission` class that handles contact form emails out of the box:

* Sends email via `wp_mail()` to the site admin.
* Supports optional Twig templates for email formatting.
* Configurable success/error messages.
* Filterable recipient via `pressgang_contact_to_email`.
* Filterable subject via `pressgang_contact_subject`.

{% code title="Contact form setup" lineNumbers="true" %}

```php
use PressGang\Forms\ContactSubmission;

ContactSubmission::init([
    'action' => 'contact_form',
    'template' => 'emails/contact.twig',  // optional
    'success_message' => __("Thanks for your message!", THEMENAME),
    'validators' => [
        new \PressGang\Forms\Validators\EmailValidator(),
        new \PressGang\Forms\Validators\MessageValidator(),
        new \PressGang\Forms\Validators\RecaptchaValidator(),
    ],
]);
```

{% endcode %}

## Validators

Validators implement the `ValidatorInterface`:

{% code title="src/Forms/Validators/ValidatorInterface.php" %}

```php
namespace PressGang\Forms\Validators;

interface ValidatorInterface {
    public function validate(): array;
}
```

{% endcode %}

The `validate()` method returns an empty array on success, or an array of error messages on failure.

### Built-in Validators

| Validator            | Purpose                                                 |
| -------------------- | ------------------------------------------------------- |
| `EmailValidator`     | Validates that a submitted email address is well-formed |
| `MessageValidator`   | Validates that a message field is not empty             |
| `RecaptchaValidator` | Validates a Google reCAPTCHA response                   |

### Creating a Custom Validator

{% code title="src/Forms/Validators/PhoneValidator.php" lineNumbers="true" %}

```php
namespace MyTheme\Forms\Validators;

use PressGang\Forms\Validators\ValidatorInterface;

class PhoneValidator implements ValidatorInterface {

    public function validate(): array {
        $phone = sanitize_text_field($_POST['phone'] ?? '');

        if (empty($phone) || !preg_match('/^\+?[\d\s\-()]+$/', $phone)) {
            return [__('Please provide a valid phone number.', THEMENAME)];
        }

        return [];
    }
}
```

{% endcode %}

## Form Template (Twig)

Your Twig form template must include a nonce field and target the `admin_post` endpoint:

{% code title="views/forms/contact.twig" lineNumbers="true" %}

```twig
<form method="post" action="{{ site.url }}/wp-admin/admin-post.php">
    <input type="hidden" name="action" value="contact_form">
    {{ function('wp_nonce_field', 'contact_form', '_wpnonce', true, false) }}

    <input type="email" name="contact[email]" value="{{ flash('contact.email') }}">
    <textarea name="contact[message]">{{ flash('contact.message') }}</textarea>

    <button type="submit">{{ __('Send', THEMENAME) }}</button>
</form>
```

{% endcode %}

## Security

{% hint style="danger" %}
All PressGang forms enforce WordPress security conventions — nonce verification, input sanitisation, and capability checks are mandatory. Never trust raw user input!
{% endhint %}

* Nonce validation is automatic — handled by `FormSubmission::handle_form_submission()`.
* All input should be sanitised using `sanitize_text_field()`, `sanitize_email()`, etc.
* Validation logic must live in validators, not in controllers.
* Controllers may only consume validated data — they must never process form submissions directly.


# SEO

PressGang includes a built-in `MetaDescriptionService` that generates smart, cached meta descriptions for every page on your site. No need for a full SEO plugin just to get decent meta tags — PressGang has you covered on this voyage.

## MetaDescriptionService

The `MetaDescriptionService` generates a single, authoritative meta description for the current page. It's available in Twig via the `meta_description()` function (registered by the `MetaDescriptionExtensionManager`).

### Usage in Twig

{% code title="views/scaffold/head.twig" %}

```twig
{{ fn('wp_head') }}
```

{% endcode %}

That's it. PressGang's SEO service provider renders its fallback `<meta name="description">` tag through `wp_head` only when a dedicated SEO plugin is not detected.

## Fallback Chain

The service uses a smart fallback chain to find the best description for each page type:

<details>

<summary><strong>Posts and Pages</strong></summary>

1. **Yoast SEO meta description** (`_yoast_wpseo_metadesc` post meta) — if Yoast is installed and a description is set, it takes priority.
2. **Custom field** (`meta_description` post meta) — a manual override without needing Yoast.
3. **Post excerpt** — the WordPress excerpt field.
4. **Post content** — falls back to the rendered content.
5. **Site tagline** — the default site description from Settings > General.

</details>

<details>

<summary><strong>Taxonomy Terms</strong></summary>

1. **Yoast SEO taxonomy meta** — if configured in Yoast's taxonomy settings.
2. **Term description** — the description field from the term editor.
3. **Site tagline** — the default fallback.

</details>

<details>

<summary><strong>Archives</strong></summary>

1. **Archive description** — via `get_the_archive_description()`.
2. **Site tagline** — the default fallback.

</details>

<details>

<summary><strong>Front Page</strong></summary>

Always uses the **site tagline** (from Settings > General > Tagline).

</details>

## Truncation

Descriptions are automatically truncated to **155 characters** (the recommended SEO limit) using smart boundary detection:

1. If the description fits within 155 characters, it's used as-is.
2. If it's longer, PressGang looks for the last full stop (period) within the limit and cuts there.
3. If no sentence boundary is found, it trims to the last complete word.

This ensures descriptions always read naturally — no awkward mid-word cutoffs.

## Caching

Meta descriptions are cached per object using `wp_cache`, so the fallback chain only runs once per page per request cycle. The cache key is based on the object type and ID.

## Hooks

| Hook                         | Type   | Available in      |
| ---------------------------- | ------ | ----------------- |
| `pressgang_contact_to_email` | filter | ContactSubmission |

{% hint style="success" %}
The `MetaDescriptionService` can read Yoast SEO data when generating fallback descriptions, but PressGang does not output its own meta description tag when Yoast SEO, Rank Math, or All in One SEO is detected. This avoids duplicate description tags while keeping the fallback service useful for themes without a dedicated SEO plugin.
{% endhint %}

## SEO Plugin Detection

PressGang checks common SEO plugin constants before rendering its fallback meta description:

* `WPSEO_VERSION` for Yoast SEO.
* `RANK_MATH_VERSION` for Rank Math.
* `AIOSEO_VERSION` for All in One SEO.

The detection and rendering decision are filterable:

{% code title="functions.php" %}

```php
add_filter( 'pressgang_has_seo_plugin', '__return_true' );
add_filter( 'pressgang_should_render_meta_description', '__return_false' );
```

{% endcode %}

## Configuration

The `SeoServiceProvider` is registered by default in `config/service-providers.php`. The `MetaDescriptionExtensionManager` is also registered by default in `config/twig-extensions.php` for themes that want to call `{{ meta_description() }}` directly.

To add `meta_description()` support to a theme, ensure the extension manager is listed:

{% code title="config/twig-extensions.php" %}

```php
return [
    \PressGang\TwigExtensions\GeneralExtensionManager::class,
    \PressGang\TwigExtensions\MetaDescriptionExtensionManager::class,
    // ...
];
```

{% endcode %}


# Testing

PHPUnit unit tests and PHPStan static analysis for PressGang's own PHP framework code — no WordPress install required — plus where to go for full theme end-to-end testing.

PressGang ships with a unit test suite so you can verify framework behaviour and safely refactor without a running WordPress installation.

## 🧰 Stack

* [**PHPUnit**](https://phpunit.de/) **9.6** — test runner
* [**yoast/wp-test-utils**](https://github.com/Yoast/wp-test-utils) **^1.2** — provides BrainMonkey integration and pre-stubbed WordPress functions (matches Timber 2's own test stack)
* [**BrainMonkey**](https://brain-wp.github.io/BrainMonkey/) — mocks WordPress functions (`add_action`, `apply_filters`, `wp_cache_get`, etc.) in pure PHP

No WordPress database, no web server, no Docker required.

## ▶️ Running Tests

{% code title="Terminal" %}

```bash
composer test            # alias for test:unit
composer test:unit       # run the full unit suite
composer test:compat     # run the unit suite with strict PHP error reporting
composer check           # local convenience: test:compat + phpstan
vendor/bin/phpunit --filter ConfigTest           # run a single test class
vendor/bin/phpunit --filter loads_and_caches     # run a single test by name
vendor/bin/phpunit --list-tests                  # list all discovered tests
```

{% endcode %}

## 🗂️ Directory Structure

Tests mirror the `src/` layout under `tests/Unit/`:

{% code title="tests/" %}

```
tests/
├── bootstrap.php              # autoloader + THEMENAME/ABSPATH constants
└── Unit/
    ├── TestCase.php           # base class (extends YoastTestCase)
    ├── Blocks/                # BlockClassManager
    ├── Bootstrap/             # Config, FileConfigLoader, Loader
    ├── Configuration/         # Sidebars, Menus, CustomPostTypes, Actions
    ├── ContextManagers/       # Menu, Site, ThemeMods, AcfOptions, WooCommerce
    └── ServiceProviders/      # TimberServiceProvider
```

{% endcode %}

## ✍️ Writing a New Test

{% stepper %}
{% step %}

#### Create the test class

Place it under `tests/Unit/` mirroring the `src/` path. For example, a test for `src/Configuration/Sidebars.php` goes in `tests/Unit/Configuration/SidebarsTest.php`.
{% endstep %}

{% step %}

#### Extend the base TestCase

{% code title="tests/Unit/Configuration/SidebarsTest.php" %}

```php
namespace PressGang\Tests\Unit\Configuration;

use PressGang\Tests\Unit\TestCase;

class SidebarsTest extends TestCase {
    // ...
}
```

{% endcode %}

The base `TestCase` extends `Yoast\WPTestUtils\BrainMonkey\YoastTestCase`, which handles BrainMonkey setup and teardown automatically. It also provides:

* `resetSingletonInstances()` — clears `ConfigurationSingleton` state between tests
* `setPostData()` / `clearPostData()` — helpers for testing form validators
  {% endstep %}

{% step %}

#### Mock WordPress functions with BrainMonkey

{% code title="Example test method" %}

```php
use Brain\Monkey\Functions;

/** @test */
public function registers_sidebars_from_config(): void {
    Functions\expect('register_sidebar')
        ->once()
        ->with(\Mockery::on(fn($args) => $args['id'] === 'main'));

    // trigger the code under test...
}
```

{% endcode %}
{% endstep %}

{% step %}

#### Reset singletons when needed

Any test that touches a `ConfigurationSingleton` subclass should reset state:

{% code title="setUp method" %}

```php
public function set_up(): void {
    parent::set_up();
    $this->resetSingletonInstances();
}
```

{% endcode %}
{% endstep %}
{% endstepper %}

## 🔧 Testing Context Managers

Context managers depend on static calls (`Timber::get_menu()`, `new Site()`) and global helpers (`config()`) that cannot be mocked directly with BrainMonkey. PressGang uses the **protected method pattern** — static calls are wrapped in protected methods that tests override via anonymous subclasses:

{% tabs %}
{% tab title="Production class" %}
{% code title="src/ContextManagers/MenuContextManager.php" %}

```php
class MenuContextManager implements ContextManagerInterface {
    protected function get_menu(string $location): ?object {
        return Timber::get_menu($location);
    }
}
```

{% endcode %}
{% endtab %}

{% tab title="Test override" %}
{% code title="tests/Unit/ContextManagers/MenuContextManagerTest.php" %}

```php
private function makeManager(): MenuContextManager {
    return new class(['primary' => $menuStub]) extends MenuContextManager {
        public function __construct(private readonly array $menuMap) {}
        protected function get_menu(string $location): ?object {
            return $this->menuMap[$location] ?? null;
        }
    };
}
```

{% endcode %}
{% endtab %}
{% endtabs %}

This avoids `@runTestsInSeparateProcesses` (which is 5-10x slower) and keeps tests fast and deterministic.

## 💡 Tips and Gotchas

### BrainMonkey `apply_filters` signature

{% hint style="warning" %}
`apply_filters` receives `($hook, $value, ...$extra)`. To pass through the value unchanged, use the pattern below.
{% endhint %}

{% code title="Correct approach" %}

```php
Functions\expect('apply_filters')
    ->andReturnUsing(fn() => func_get_args()[1]);
```

{% endcode %}

{% hint style="danger" %}
Do **not** use `andReturnFirstArg()` — that returns the hook name, not the value.
{% endhint %}

### Pre-loaded functions cannot be mocked

Functions loaded via Composer's `files` autoload (like the `config()` helper) are defined before BrainMonkey initialises. Extract calls to these functions into protected methods and override them in tests.

### `wp_parse_args` is pre-stubbed

{% hint style="info" %}
`YoastTestCase` pre-stubs `wp_parse_args` to behave like `array_merge($defaults, $args)` — no need to mock it yourself.
{% endhint %}

## 🔬 Static Analysis

PressGang also runs [PHPStan](https://phpstan.org/) at level 8, so type errors and nullability bugs are caught before a test even needs to exist for them.

{% code title="Terminal" %}

```bash
composer phpstan
```

{% endcode %}

* **`szepeviktor/phpstan-wordpress`** — WordPress core function/class stubs
* **`php-stubs/woocommerce-stubs`** and **`php-stubs/acf-pro-stubs`** — stubs for the two plugin APIs PressGang integrates with most deeply

`composer check` is intentionally narrow: it runs `test:compat` and `phpstan`, and nothing else. Keep CI jobs split into their existing separate steps so the Actions UI still shows whether tests, compatibility, static analysis, or browser/runtime checks failed.

For controller manifests and getter-backed model access, install the [PressGang PHPStan extension](/ecosystem/phpstan) in the child theme. Its guide covers installation, automatic checks and optional manifest-omission advice.

## 🧭 Theme tooling convention

Child themes should use the same shape:

* PHPStan level 8 with a local `phpstan.neon.dist`.
* `composer phpstan` for static analysis.
* `composer test:compat` for the strict PHP compatibility/unit pass when the theme has tests.
* `composer check` as a local convenience alias for `test:compat` + `phpstan` only.
* Project stubs for vendor/runtime type mismatches when they model reality better than an ignore.
* Explicit, documented ignores only when a source fix, docblock improvement, or stub would be worse.

Agents should run `composer check` when it exists. If it does not, run the project's documented test and static-analysis commands separately. Treat PHPStan findings as guidance for improving source, PHPDoc, stubs, or config; do not add baselines or broad ignores unless a maintainer explicitly asks.

{% hint style="info" %}
There is no PHPStan baseline in this repo. If `composer phpstan` fails on something you wrote, fix the type rather than baselining it.
{% endhint %}

A couple of ignored rules in `phpstan.neon.dist` are architectural, not suppressed bugs — see **Known Exceptions** in [AGENTS.md](https://github.com/pressgang-wp/pressgang/blob/main/AGENTS.md) for why `trait.unused` and `CustomMenuItems.php`'s dynamic `WP_Post` properties are ignored.

## 🧱 Tool boundaries

Static analysis, runtime introspection, and end-to-end verification are separate signals:

* PHPStan catches static type, nullability, and convention drift in PHP code.
* [Capstan](/ecosystem/capstan) provides runtime introspection with `wp capstan resolve`, `wp capstan context`, `wp capstan config dump`, and `wp capstan doctor`.
* [Shakedown](/ecosystem/shakedown) uses Capstan's `wp capstan matrix --resolve` oracle to assert route/controller/runtime behaviour in CI.

Do not design new route, controller, context, or config-dump validation here: those surfaces already exist in Capstan and Shakedown.

The [convention extension](/ecosystem/phpstan) is separate from shared project stubs and ignore lists. Track repeated extraction candidates — nav-menu `WP_Post` dynamic properties, WooCommerce cart lifecycle stubs, WooCommerce/ACF stub bundling, and `phpstan-bootstrap.php` constants — but extract only after a second PressGang repo independently adopts PHPStan level 8 and hits the same needs. One consumer is not a package.

## 🚢 End-to-end testing

Unit tests cover the framework's PHP in isolation. For testing an actual **theme** — every route rendered in a real browser, accessibility, visual regression, derived fixtures — see [Shakedown](/ecosystem/shakedown), the fleet's e2e harness. It needs zero authored tests to start: the suite is derived from your theme's config.


# PHPStan Extension

Static analysis for PressGang controller manifests, dynamic model getters and getter-backed metadata access in child themes.

`pressgang-wp/phpstan` teaches PHPStan about conventions that ordinary PHP type checking cannot see: controller context manifests and model getter dispatch. It analyses PHP source without booting WordPress or querying a database.

Use it alongside `szepeviktor/phpstan-wordpress` and the plugin stubs your theme needs. It does not replace those packages or [Capstan's runtime checks](/ecosystem/capstan).

## Install in a child theme

The extension requires PHP 8.2+ and PHPStan 2.2.9+. PressGang itself requires PHP 8.3+, so the framework requirement still applies to your theme.

For the current local, unpublished package, add a path repository pointing to your checkout. Replace the example path with its location on your machine:

```bash
composer config repositories.pressgang-phpstan path /path/to/pressgang-phpstan
composer config allow-plugins.phpstan/extension-installer true
composer require --dev pressgang-wp/phpstan:@dev phpstan/extension-installer:^1.4 \
  szepeviktor/phpstan-wordpress:^2.0
```

If the theme uses ACF Pro, also install its analysis stubs:

```bash
composer require --dev php-stubs/acf-pro-stubs:^6.8
```

`phpstan/extension-installer` automatically loads the package's `extension.neon`. Do not include it again in your theme config. Without the installer, include `vendor/pressgang-wp/phpstan/extension.neon` manually.

Start with a `phpstan.neon.dist` appropriate to your theme:

```neon
parameters:
    level: 5
    paths:
        - src
    scanFiles:
        - vendor/php-stubs/acf-pro-stubs/acf-pro-stubs.php
```

Omit `scanFiles` if you did not install the ACF stubs. The theme, framework and Timber classes must be discoverable through Composer autoloading. A migration can start at level 5 and increase its level as findings are resolved; level 8 remains the framework's standard and the target for child themes.

Add a script to the theme's existing Composer scripts, preserving its other checks:

```json
{
    "scripts": {
        "phpstan": "phpstan analyse --memory-limit=1G"
    }
}
```

Run `composer phpstan`, and include it in your existing `composer check` workflow. See [Testing](/testing) for the broader testing conventions.

## Controller context manifests

A controller's manifest declares which getters supply template context:

```php
namespace YourTheme\Controllers;

use PressGang\Controllers\AbstractController;

class ArticlesController extends AbstractController {
    /** @var array<int|string, string> */
    protected array $context_getters = [
        'heading',
        'summary' => 'build_summary',
    ];

    /** @return string */
    protected function get_heading(): string {
        return 'Articles';
    }

    /** @return string */
    protected function build_summary(): string {
        return 'Recent updates';
    }
}
```

The numeric entry `'heading'` calls `get_heading()`. The alias entry calls `build_summary()` and places its value under `summary`. A missing method produces `pressgang.contextGetterMissing`, rather than waiting for the page to execute the dynamic call.

Getters may be inherited from parent controllers or supplied by traits. An inherited manifest is checked against the child class; a child declaration replaces the parent manifest rather than merging with it. Manifest references also participate in PHPStan's unused-method extension point.

The resolver reads source-reflected property defaults. Keep manifests declarative: it does not follow constructor mutations or runtime assignments, and it skips defaults it cannot resolve to string entries.

## Getter-backed model properties and metadata

A Timber model using `HandlesDynamicGetters` exposes a getter through both property access and `meta()`:

```php
namespace YourTheme\Models;

use PressGang\Traits\HandlesDynamicGetters;
use Timber\Post;

class Article extends Post {
    use HandlesDynamicGetters;

    /** @return string */
    public function get_display_title(): string {
        return $this->post_title;
    }
}
```

For an `Article`, both `$article->display_title` and `$article->meta('display_title')` now have type `string`. Nullable types and PHPDoc generics are preserved, including for inherited or trait getters.

Virtual properties are readable, not setters. Native properties retain their own types. Only actual getter methods count; an arbitrary `@method` annotation does not establish a getter contract.

`meta()` narrowing requires one known constant-string field name. Dynamic keys, unknown getters, uncertain unions, unpacked arguments and the falsey names `''` and `'0'` keep the declared return type. Named arguments are supported.

Getter dispatch happens before `parent::meta()`. Timber's `transform_value` option therefore does not change a custom getter's declared return type. Ordinary ACF fields retain their declared type, usually `mixed`; the extension does not infer field shapes from runtime ACF configuration. See [ACF Values](/acf-values) for transformation behaviour.

The trait is detected through inheritance and nested traits. Overridden `__get`, `meta`, `has_custom_getter` or `call_custom_getter` implementations are left to their declared behaviour. The supported getter-argument resolution hook may still be overridden. `AbstractController` itself currently does not use `HandlesDynamicGetters`.

### Non-Timber trait users

The property extension and recursion rule apply to all trait users. PHPStan's method return-type API registers against a class or interface, so the default `meta()` registration targets `Timber\CoreEntity`. For another base class using the trait, add a registration:

```neon
services:
    -
        class: PressGang\PHPStan\Type\MetaReturnTypeExtension
        arguments:
            baseClass: YourTheme\Models\ModelBase
        tags:
            - phpstan.broker.dynamicMethodReturnTypeExtension
```

## Avoiding getter/meta recursion

This getter calls itself indirectly through the trait:

```php
/** @return mixed */
public function get_label(): mixed {
    return $this->meta( 'label' );
}
```

The extension reports `pressgang.recursiveMetaGetter`. Either rename the method without the `get_` prefix, or read the parent implementation directly:

```php
/** @return mixed */
public function get_label(): mixed {
    return parent::meta( 'label' );
}
```

The rule checks direct `$this->meta()` calls with a matching constant string inside the getter. It does not follow cycles through other methods or nested closure bodies.

## Optional manifest-omission advice

Enable advisory checks explicitly:

```neon
includes:
    - vendor/pressgang-wp/phpstan/rules.neon
```

`pressgang.contextGetterOrphan` reports `get_*()` methods absent from a concrete controller's effective manifest, including inherited and trait methods. Abstract controllers and the lifecycle methods `get_context()` and `get_template_candidates()` are excluded from this advice.

An omission is not proof of dead code: another getter, a lifecycle method or a hook may call the method. The rule does not build a direct-call reachability graph. Mark an intentional helper on its method PHPDoc:

```php
/**
 * Supplies a label to another getter rather than a standalone context key.
 *
 * @pressgang-context-helper
 * @return string
 */
protected function get_label(): string {
    return 'Latest';
}
```

The annotation suppresses only the orphan advisory. It does not suppress missing methods or recursion. PHPStan has no separate warning severity: opting in makes these findings affect its normal exit status. Review callers before removing a getter; absence from a manifest does not itself cause a query to run.

## Themes with blocks

Include `src/Blocks` in your analysis paths (analysing all of `src` includes it). Block subclasses use ordinary static `get_context()` and `render()` methods; PHPStan checks those calls and overrides directly. They do not use controller manifests, so block `get_*()` helpers do not need `@pressgang-context-helper`.

Document a custom `get_context()` input as `array<string, mixed>` and its return as `array<string, mixed>`, retaining the framework's native `mixed` parameter. Inherited implementations, trait helpers and intentionally replaced contexts are supported; calling `parent::get_context()` is not mandatory. Model getters used inside blocks receive the same property and `meta()` typing as elsewhere.

The extension does not promise a fixed block-context shape: ACF fields can replace context keys, and their values depend on runtime field configuration. Block registration, callback wiring and template discovery need separate runtime checks.

## Handling findings and scope

Fix incorrect source or return contracts first. The extension preserves ordinary PHPStan errors: it will not turn a base-class collection into a promised subclass array, make a missing Timber property exist, or narrow a nullable collection just because a controller uses a manifest.

Keep the framework baseline-free. In a child-theme migration, a baseline should be an explicitly agreed inventory of existing debt, not a blanket ignore for `pressgang.*` diagnostics. Review optional advisories separately from automatic checks, and retain a way to inspect unbaselined findings.

| Question                                                | Tool                                          |
| ------------------------------------------------------- | --------------------------------------------- |
| Does a manifest name an existing getter?                | PHPStan extension                             |
| What type does a custom model getter expose?            | PHPStan extension                             |
| Does a getter recurse through its own metadata key?     | PHPStan extension                             |
| Which namespace, snippets and merged config are active? | `wp capstan doctor`                           |
| Does a route render correctly in a real theme?          | Capstan and [Shakedown](/ecosystem/shakedown) |

Config return-shape maps and Twig/template validation are outside this extension. Runtime namespace resolution, installed packages and merged configuration need a booted WordPress environment; static analysis should not guess those values.


# Quartermaster

A fluent, args-first query builder for WP\_Query and WP\_Term\_Query. WordPress-native under the hood — no ORM, no magic, no lock-in.

Quartermaster is a fluent, args-first query builder for `WP_Query` and `WP_Term_Query`. It helps you build complex query arrays in readable, composable steps while staying **100% WordPress-native under the hood**.

Think of it as a reliable quartermaster for your query cargo — you decide what goes aboard, nothing gets smuggled in. 🧭

{% hint style="success" %}
Quartermaster ships as a **standalone package** in the PressGang ecosystem. It does **not** depend on the PressGang theme framework — you can use it in any WordPress project.
{% endhint %}

## 📦 Install

{% code title="Terminal" %}

```bash
composer require pressgang-wp/quartermaster
```

{% endcode %}

**Requirements:** PHP 8.3+

***

## ✨ Why Fluent?

`WP_Query` arrays are powerful, but as they grow they become harder to scan, review, and refactor.

```mermaid
graph LR
    A["✍️ Fluent methods"] --> B["📦 Plain WP args"]
    B --> C["⚙️ WP_Query / WP_Term_Query"]
    C --> D["📋 Posts or Terms"]
```

|     | Benefit                  | How                                                   |
| --- | ------------------------ | ----------------------------------------------------- |
| 📖  | **Better readability**   | Query intent is expressed step-by-step                |
| 🧩  | **Better composability** | Add or remove clauses without rewriting a large array |
| 🛡️ | **Better safety**        | Methods are explicit about which WP args they set     |
| 🔍  | **Better debugging**     | Inspect exact output with `toArgs()` and `explain()`  |

You still end up with **plain WordPress args**. No ORM. No hidden query engine. No lock-in. Just well-organised cargo. ⚓

{% hint style="info" %}
Sometimes raw `WP_Query` is fine — if your query is short and static, use it. Quartermaster shines when queries evolve, branch, or need to be composed without losing your bearings.
{% endhint %}

***

## 👀 Before and After

Three real queries, as typically written in a theme — and the same queries with Quartermaster. In every case the "after" produces a **plain `WP_Query` args array** you can inspect with `toArgs()`.

### 1. Upcoming events, soonest first

The classic meta-date query: filter by an ACF date field, order by the same field, paginate.

{% tabs %}
{% tab title="😩 Before" %}
{% code title="Controller" lineNumbers="true" %}

```php
$events = new WP_Query([
    'post_type'      => 'event',
    'post_status'    => 'publish',
    'posts_per_page' => 12,
    'paged'          => max(1, (int) get_query_var('paged')),
    'meta_key'       => 'start',
    'orderby'        => 'meta_value',
    'order'          => 'ASC',
    'meta_query'     => [
        [
            'key'     => 'start',
            'value'   => wp_date('Ymd'),
            'compare' => '>=',
            'type'    => 'DATE',
        ],
    ],
]);
```

{% endcode %}

Four things have to line up silently: `meta_key` must match the `meta_query` key, `orderby => meta_value` breaks without `meta_key`, the date format has to match ACF's storage, and `wp_date()` vs `date()` decides whether events flip a day at midnight in the wrong timezone.
{% endtab %}

{% tab title="⚓ After" %}
{% code title="Controller" lineNumbers="true" %}

```php
$events = Quartermaster::posts('event')
    ->status('publish')
    ->paged(12)
    ->whereMetaDate('start', '>=')
    ->orderByMeta('start', 'ASC')
    ->wpQuery();
```

{% endcode %}

`whereMetaDate()` defaults to today via timezone-aware `wp_date()` in ACF's `Ymd` format, and `orderByMeta()` sets `meta_key` and `orderby` together — the pair that's easiest to get wrong by hand. If you *do* set `orderby => meta_value` without a key, `explain()` warns you.
{% endtab %}
{% endtabs %}

### 2. Optional filters

Filters that may or may not have a value — from an ACF field, a route param, a widget setting. Raw arrays force you to build conditionally.

{% tabs %}
{% tab title="😩 Before" %}
{% code title="Controller" lineNumbers="true" %}

```php
$args = [
    'post_type'      => 'post',
    'post_status'    => 'publish',
    'posts_per_page' => 9,
];

$tax_query = [];

if (! empty($topic)) {
    $tax_query[] = [
        'taxonomy' => 'topic',
        'field'    => 'slug',
        'terms'    => $topic,
    ];
}

if (! empty($region)) {
    $tax_query[] = [
        'taxonomy' => 'region',
        'field'    => 'slug',
        'terms'    => $region,
    ];
}

if ($tax_query) {
    $args['tax_query'] = $tax_query;
}

if (! empty($exclude_ids)) {
    $args['post__not_in'] = $exclude_ids;
}

$posts = get_posts($args);
```

{% endcode %}

Most of the code is scaffolding around *maybe* having a value — and an accidental `'tax_query' => []` or `'post__not_in' => []` is a real bug (an empty `post__not_in` array changes nothing, but an empty *string* ID list can). The query's intent is buried.
{% endtab %}

{% tab title="⚓ After" %}
{% code title="Controller" lineNumbers="true" %}

```php
$posts = Quartermaster::posts('post')
    ->status('publish')
    ->limit(9)
    ->whereTax('topic', $topic ?: null)
    ->whereTax('region', $region ?: null)
    ->excludeIds($exclude_ids)
    ->get();
```

{% endcode %}

`whereTax()` treats `null` and empty terms as "no filter", and `excludeIds([])` is a no-op — so optional values pass straight through and the chain reads as the query it is. No `if` scaffolding, no empty-clause bugs.
{% endtab %}
{% endtabs %}

### 3. A filterable archive

URL-driven filtering — taxonomy facets, a numeric range, search — is where raw args sprawl fastest.

{% tabs %}
{% tab title="😩 Before" %}
{% code title="Controller" lineNumbers="true" %}

```php
$args = [
    'post_type'      => 'route',
    'post_status'    => 'publish',
    'posts_per_page' => 12,
    'paged'          => max(1, (int) get_query_var('paged')),
];

if ($shape = get_query_var('shape')) {
    $args['tax_query'][] = [
        'taxonomy' => 'route_shape',
        'field'    => 'slug',
        'terms'    => sanitize_text_field($shape),
    ];
}

if (($min = get_query_var('min_distance')) !== '') {
    $args['meta_query'][] = [
        'key'     => 'distance_miles',
        'value'   => (float) $min,
        'compare' => '>=',
        'type'    => 'NUMERIC',
    ];
}

if ($search = get_query_var('search')) {
    $args['s'] = sanitize_text_field($search);
}

$query = new WP_Query($args);
```

{% endcode %}

Every filter repeats the same ritual: read, check, sanitise, append. Each new facet grows the block, and it's easy to forget the sanitising step on just one of them.
{% endtab %}

{% tab title="⚓ After" %}
{% code title="Controller" lineNumbers="true" %}

```php
use PressGang\Quartermaster\Bindings\Bind;

$query = Quartermaster::posts('route')
    ->status('publish')
    ->bindQueryVars([
        'paged'        => Bind::paged(),
        'shape'        => Bind::tax('route_shape'),
        'min_distance' => Bind::metaNum('distance_miles', '>='),
        'search'       => Bind::search(),
    ])
    ->paged(12)
    ->wpQuery();
```

{% endcode %}

Each binding reads, validates, sanitises, and skips-when-empty in one declaration — and `explain()` logs every binding attempt, including why one was skipped. Adding a facet is one line.
{% endtab %}
{% endtabs %}

{% hint style="success" %}
Nothing was lost in translation: call `->toArgs()` on any of the "after" chains and you get back exactly the kind of array the "before" code built by hand. ⚓
{% endhint %}

***

## 🧠 Design Philosophy

Quartermaster is intentionally light-touch. Steady hands on the wheel, predictable seas ahead. 🚢

| Principle              | Meaning                                                                     |
| ---------------------- | --------------------------------------------------------------------------- |
| 🧱 WordPress-native    | Every fluent method maps directly to real `WP_Query` / `WP_Term_Query` keys |
| 🫙 Zero side effects   | `Quartermaster::posts()->toArgs()` returns `[]` — nothing gets smuggled in  |
| 🎯 Opt-in only         | Nothing changes unless you call a method                                    |
| 🔌 Loosely coupled     | No mutation of WordPress internals, no global state changes                 |
| 🌲 Timber-agnostic     | Timber support is optional and runtime-guarded                              |
| 🧭 Explicit over magic | Sharp WP edges are documented, not hidden                                   |

{% hint style="warning" %}
**Non-goals** — Quartermaster deliberately does **not** aim to replace `WP_Query`, act as an ORM, hide WordPress limitations (e.g. tax/meta OR logic), or infer defaults. If WordPress requires a specific argument shape, Quartermaster expects you to be explicit. No fog, no siren songs. 🧜‍♀️
{% endhint %}

***

## 🚀 Posts — Quick Start

`posts('event')` is a convenience seed — it only sets `post_type` and does not infer any other query args.

{% tabs %}
{% tab title="🔧 Build args" %}
Build an args array without executing the query:

{% code title="Controller" lineNumbers="true" %}

```php
use PressGang\Quartermaster\Quartermaster;

$args = Quartermaster::posts()
    ->postType('event')
    ->status('publish')
    ->paged(10)
    ->orderByMeta('start', 'ASC')
    ->search(get_query_var('s'))
    ->toArgs();

// $args is a plain WP_Query-compatible array — use it however you like
$query = new WP_Query($args);
```

{% endcode %}
{% endtab %}

{% tab title="📋 Get posts" %}
Execute the query and get posts in one step:

{% code title="Controller" lineNumbers="true" %}

```php
$posts = Quartermaster::posts()
    ->postType('event')
    ->status('publish')
    ->limit(6)
    ->get();

// $posts is an array of WP_Post objects
```

{% endcode %}
{% endtab %}

{% tab title="🔄 Full WP\_Query" %}
When you need the full `WP_Query` object (pagination metadata, found rows, loop state):

{% code title="Controller" lineNumbers="true" %}

```php
$query = Quartermaster::posts()
    ->postType('event')
    ->status('publish')
    ->paged(12)
    ->wpQuery();

$posts      = $query->posts;
$total      = $query->found_posts;
$totalPages = $query->max_num_pages;
```

{% endcode %}
{% endtab %}

{% tab title="🌲 Timber" %}
Get Timber `PostQuery` objects directly — ideal for PressGang controllers:

{% code title="Controller" lineNumbers="true" %}

```php
$posts = Quartermaster::posts()
    ->postType('event')
    ->status('publish')
    ->paged(12)
    ->timber();
```

{% endcode %}

{% hint style="info" %}
Timber is optional and runtime-guarded. If Timber is unavailable, Quartermaster throws a clear `RuntimeException` rather than hard-coupling Timber into core.
{% endhint %}
{% endtab %}

{% tab title="🪝 Apply to existing query" %}
Modify an existing `WP_Query` in a `pre_get_posts` hook — scalar args are set directly, clause arrays (`tax_query`, `meta_query`, `date_query`) are merged with existing clauses:

{% code title="functions.php" lineNumbers="true" %}

```php
add_action('pre_get_posts', function (WP_Query $query): void {
    if (! $query->is_main_query() || is_admin()) {
        return;
    }

    Quartermaster::posts('product')
        ->whereTax('product_visibility', ['exclude-from-catalog'], 'name', 'NOT IN')
        ->whereMetaExists('_price')
        ->applyTo($query);
});
```

{% endcode %}

{% hint style="info" %}
`applyTo()` is a terminal — it does not return the builder. Clause arrays from multiple hooks compose safely because clauses are merged, not overwritten.
{% endhint %}
{% endtab %}
{% endtabs %}

***

## 🪝 Query Hooks (`pre_get_posts`)

`applyTo()` lets you use the full Quartermaster API inside WordPress query hooks like `pre_get_posts`. Instead of creating a new query, it modifies an existing `WP_Query` in place.

{% tabs %}
{% tab title="Basic" %}
{% code title="functions.php" lineNumbers="true" %}

```php
use PressGang\Quartermaster\Quartermaster;

add_action('pre_get_posts', function (WP_Query $query): void {
    if (! $query->is_main_query() || is_admin()) {
        return;
    }

    Quartermaster::posts('product')
        ->status('publish')
        ->limit(12)
        ->applyTo($query);
});
```

{% endcode %}
{% endtab %}

{% tab title="Merging clauses" %}
Clause arrays (`tax_query`, `meta_query`, `date_query`) are **merged** with any clauses already on the query — they are never overwritten. This means multiple hooks can safely compose:

{% code title="functions.php" lineNumbers="true" %}

```php
// Hook A: exclude hidden products
add_action('pre_get_posts', function (WP_Query $query): void {
    if (! $query->is_main_query() || ! is_post_type_archive('product')) {
        return;
    }

    Quartermaster::prepare()
        ->whereTax('product_visibility', ['exclude-from-catalog'], 'name', 'NOT IN')
        ->applyTo($query);
});

// Hook B: only show priced products
add_action('pre_get_posts', function (WP_Query $query): void {
    if (! $query->is_main_query() || ! is_post_type_archive('product')) {
        return;
    }

    Quartermaster::prepare()
        ->whereMetaExists('_price')
        ->whereMeta('_price', '', '!=')
        ->applyTo($query);
});

// Both tax_query and meta_query clauses are merged — not overwritten
```

{% endcode %}
{% endtab %}

{% tab title="With conditionals" %}
Combine `when()` and `applyTo()` for conditional hook logic:

{% code title="functions.php" lineNumbers="true" %}

```php
add_action('pre_get_posts', function (WP_Query $query): void {
    if (! $query->is_main_query() || ! is_post_type_archive('event')) {
        return;
    }

    $isArchive = isset($_GET['archive']);

    Quartermaster::prepare()
        ->when($isArchive, fn ($q) =>
            $q->whereMetaDate('start', '<')->orderByMeta('start', 'DESC')
        )
        ->when(! $isArchive, fn ($q) =>
            $q->whereMetaDate('start', '>=')->orderByMeta('start', 'ASC')
        )
        ->applyTo($query);
});
```

{% endcode %}
{% endtab %}
{% endtabs %}

{% hint style="warning" %}
`applyTo()` is a **void terminal** — it does not return the builder. If you need to inspect the args that were applied, hold a reference to the builder and call `explain()` separately.
{% endhint %}

{% hint style="info" %}
**Relation precedence:** when merging clause arrays, the existing query's relation takes precedence over the builder's. If the query already has `relation => OR` on its `meta_query`, `applyTo()` will not overwrite it with `AND`. This ensures earlier hooks aren't silently overridden.
{% endhint %}

***

## 📌 Post Constraints

Filter by ID, parent, author, and status — all with type-safe integer handling.

{% tabs %}
{% tab title="IDs" %}
{% code title="Controller" lineNumbers="true" %}

```php
// Single post by ID
$q = Quartermaster::posts()->whereId(42);

// Multiple specific posts
$q = Quartermaster::posts()->whereInIds([10, 20, 30]);

// Exclude specific posts
$q = Quartermaster::posts('post')
    ->excludeIds([$stickyPost->ID])
    ->status('publish');
```

{% endcode %}

{% hint style="info" %}
All ID methods filter non-integer values automatically and skip silently if the resulting list is empty. No invalid args, no broken queries.
{% endhint %}
{% endtab %}

{% tab title="Parents" %}
{% code title="Controller" lineNumbers="true" %}

```php
// Direct children of a page
$children = Quartermaster::posts('page')
    ->whereParent($parentPage->ID)
    ->orderBy('menu_order', 'ASC')
    ->get();

// Children of multiple parents
$q = Quartermaster::posts('page')
    ->whereParentIn([10, 20, 30]);
```

{% endcode %}
{% endtab %}

{% tab title="Authors" %}
{% code title="Controller" lineNumbers="true" %}

```php
// Posts by one author
$q = Quartermaster::posts()->whereAuthor($userId);

// Posts by multiple authors
$q = Quartermaster::posts()->whereAuthorIn([1, 5, 12]);

// Exclude authors
$q = Quartermaster::posts()
    ->whereAuthorNotIn([$botUserId])
    ->status('publish');
```

{% endcode %}
{% endtab %}
{% endtabs %}

***

## 🔎 Meta Queries

Build `meta_query` clauses fluently — from simple key/value checks to complex nested conditions.

{% tabs %}
{% tab title="Basic" %}
{% code title="Controller" lineNumbers="true" %}

```php
// Exact match
$q = Quartermaster::posts('event')
    ->whereMeta('featured', '1');

// Numeric comparison
$q = Quartermaster::posts('product')
    ->whereMeta('price', 50, '>=', 'NUMERIC');

// Multiple AND conditions
$q = Quartermaster::posts('event')
    ->whereMeta('featured', '1')
    ->whereMeta('capacity', 0, '>', 'NUMERIC');
```

{% endcode %}
{% endtab %}

{% tab title="OR conditions" %}
{% code title="Controller" lineNumbers="true" %}

```php
// Posts matching ANY of these conditions
$q = Quartermaster::posts('event')
    ->orWhereMeta('priority', 'high')
    ->orWhereMeta('featured', '1');
```

{% endcode %}

This sets `relation => OR` on the root meta query — matching posts where **either** condition is true.
{% endtab %}

{% tab title="Dates" %}
{% code title="Controller" lineNumbers="true" %}

```php
// Upcoming events (meta date >= today)
$q = Quartermaster::posts('event')
    ->whereMetaDate('start', '>=')
    ->orderByMeta('start', 'ASC');

// Past events (meta date < today)
$q = Quartermaster::posts('event')
    ->whereMetaDate('start', '<')
    ->orderByMeta('start', 'DESC');

// Events after a specific date
$q = Quartermaster::posts('event')
    ->whereMetaDate('start', '>=', '20250601');
```

{% endcode %}

{% hint style="info" %}
When no value is provided, `whereMetaDate()` uses today's date via WordPress's timezone-aware `wp_date()`. The default format is `Ymd` (ACF convention).
{% endhint %}
{% endtab %}

{% tab title="Existence" %}
{% code title="Controller" lineNumbers="true" %}

```php
// Posts that HAVE a video URL set
$q = Quartermaster::posts('lesson')
    ->whereMetaExists('video_url');

// Posts that DON'T have a legacy field
$q = Quartermaster::posts('product')
    ->whereMetaNotExists('legacy_sku');

// Exclude by value, including posts where key doesn't exist
$q = Quartermaster::posts('event')
    ->whereMetaNot('cancelled', '1');
```

{% endcode %}

{% hint style="info" %}
`whereMetaNot()` creates a smart nested OR sub-group: `(!= value OR NOT EXISTS)`. This catches posts where the key was never set — a common gotcha with raw `meta_query`.
{% endhint %}
{% endtab %}

{% tab title="Page template" %}
{% code title="Controller" lineNumbers="true" %}

```php
// Pages assigned to a given page template
$q = Quartermaster::posts('page')
    ->status('publish')
    ->wherePageTemplate('page-templates/landing.php');
```

{% endcode %}

{% hint style="info" %}
WordPress stores the selected template in the `_wp_page_template` meta key, so `wherePageTemplate()` is a shorthand for `whereMeta('_wp_page_template', $template)`. Pass the stored value: the template path relative to the theme, or the bare slug for file-less templates. Pages with no template selected store `default`.
{% endhint %}
{% endtab %}

{% tab title="ACF serialized" %}
{% code title="Controller" lineNumbers="true" %}

```php
// Match ACF relationship/checkbox fields stored as serialized arrays
$q = Quartermaster::posts('team_member')
    ->whereMetaLikeAny('department_ids', ['15', '42', '99']);
```

{% endcode %}

Builds a nested OR group of `LIKE` clauses targeting ACF's serialization format. Each value is wrapped in double-quotes to match the stored representation.

For one related post, use `->whereMetaLikeAny('projects', [$post->ID])` instead of manually constructing a quoted `LIKE` value. Pass raw values: the helper adds the quotes. This targets serialized string IDs, not serialized integers or arbitrary substrings. An empty array adds no constraint, so keep an explicit guard when an empty selection must return no posts.
{% endtab %}
{% endtabs %}

***

## 🏷️ Taxonomy Queries

{% code title="Controller" lineNumbers="true" %}

```php
// Posts in a specific category (by slug)
$q = Quartermaster::posts('post')
    ->whereTax('category', 'news');

// Posts tagged with any of several tags
$q = Quartermaster::posts('post')
    ->whereTax('post_tag', ['php', 'wordpress', 'timber']);

// Posts in a category AND with a specific tag
$q = Quartermaster::posts('post')
    ->whereTax('category', 'tutorials')
    ->whereTax('post_tag', 'advanced');

// Query by term ID instead of slug
$q = Quartermaster::posts('product')
    ->whereTax('product_cat', [12, 34], 'term_id');

// Exclude a taxonomy term
$q = Quartermaster::posts('post')
    ->whereTax('category', 'uncategorized', 'slug', 'NOT IN');

// OR relation between clauses — in EITHER category
$q = Quartermaster::posts('post')
    ->whereTax('category', 'news')
    ->orWhereTax('category', 'events');
```

{% endcode %}

Multiple `whereTax()` calls combine as AND; the first `orWhereTax()` switches the relation to OR (mirroring `orWhereMeta()`).

### Optional filters — no `when()` wrappers needed

`whereTax()` treats `null` and empty terms as "no filter": the builder is simply left unchanged. So values that may or may not be present pass straight through:

{% code title="Controller" lineNumbers="true" %}

```php
// $topic comes from a query var, an ACF field, a route param... or not at all.
$q = Quartermaster::posts('post')
    ->whereTax('topic', $topic ?: null)
    ->whereTax('region', $region ?: null);
```

{% endcode %}

The `?: null` matters when the source hands you `false` (Twig often does) — `false` isn't a term, `null` is a no-op. `excludeIds([])` is likewise a no-op, so plucked ID lists can be passed unconditionally. Save `when()` for genuine conditional *logic*, not optional values.

***

## 📅 Date Queries

{% code title="Controller" lineNumbers="true" %}

```php
// Posts published after a date
$q = Quartermaster::posts()
    ->whereDateAfter('2025-01-01');

// Posts published before a date
$q = Quartermaster::posts()
    ->whereDateBefore('2024-12-31');

// Posts in a date range
$q = Quartermaster::posts()
    ->whereDateAfter('2025-01-01')
    ->whereDateBefore('2025-06-30');

// Raw date clause for full WordPress date_query power
$q = Quartermaster::posts()
    ->whereDate(['year' => 2025, 'monthnum' => 6]);
```

{% endcode %}

***

## 📄 Pagination and Performance

{% tabs %}
{% tab title="Pagination" %}
{% code title="Controller" lineNumbers="true" %}

```php
// Standard pagination — reads current page from query vars
$q = Quartermaster::posts('event')
    ->status('publish')
    ->paged(12);

// Fixed limit without pagination context
$featured = Quartermaster::posts('post')
    ->whereMeta('featured', '1')
    ->limit(3)
    ->get();

// Fetch ALL matching posts
$allEvents = Quartermaster::posts('event')
    ->status('publish')
    ->all()
    ->get();
```

{% endcode %}

`all()` sets `posts_per_page=-1`, sets `nopaging=true` and removes `paged`. `limit(-1)` only changes `posts_per_page`. Prefer `all()` for intentionally unpaginated post queries; check pagination before changing an existing chain. This helper is not available on the terms builder.
{% endtab %}

{% tab title="Performance flags" %}
{% code title="Controller" lineNumbers="true" %}

```php
// Skip SQL_CALC_FOUND_ROWS when you don't need total count
$q = Quartermaster::posts('post')
    ->noFoundRows()
    ->limit(5);

// Return IDs only for maximum performance
$ids = Quartermaster::posts('event')
    ->idsOnly()
    ->status('publish')
    ->get();

// Ignore sticky posts — prevent them being prepended to results
$q = Quartermaster::posts('post')
    ->ignoreStickyPosts()
    ->status('publish');

// Control cache priming
$q = Quartermaster::posts('post')
    ->withMetaCache(false)   // Skip meta cache priming
    ->withTermCache(false);  // Skip term cache priming
```

{% endcode %}

{% hint style="info" %}
Use `noFoundRows()` and `idsOnly()` when you don't need the full post objects or pagination counts. These can significantly reduce query overhead on high-traffic pages.
{% endhint %}
{% endtab %}
{% endtabs %}

### First match

When you only need one post, `first()` returns it directly, or `null` when nothing matches. It runs on a copy of the builder with `posts_per_page = 1` and `no_found_rows = true`, and ignores any `paged` state, so the builder is left untouched.

{% code title="Controller" lineNumbers="true" %}

```php
$landing = Quartermaster::posts('page')
    ->status('publish')
    ->wherePageTemplate('page-templates/landing.php')
    ->first();
```

{% endcode %}

{% hint style="info" %}
Like `toArray()`, `first()` returns a `Timber\Post` when Timber is available and a `WP_Post` otherwise.
{% endhint %}

***

## ↕️ Ordering

{% tabs %}
{% tab title="Basic" %}
{% code title="Controller" lineNumbers="true" %}

```php
// Order by date (descending is default)
$q = Quartermaster::posts()->orderBy('date', 'DESC');

// Shorthand
$q = Quartermaster::posts()->orderByDesc('date');
$q = Quartermaster::posts()->orderByAsc('title');

// Order by menu order
$q = Quartermaster::posts('page')->orderBy('menu_order', 'ASC');
```

{% endcode %}
{% endtab %}

{% tab title="Meta ordering" %}
{% code title="Controller" lineNumbers="true" %}

```php
// Order by meta value (string comparison)
$q = Quartermaster::posts('event')
    ->orderByMeta('start', 'ASC');

// Shorthand
$q = Quartermaster::posts('event')
    ->orderByMetaAsc('start');

// Numeric meta ordering (for prices, distances, etc.)
$q = Quartermaster::posts('product')
    ->orderByMetaNumericDesc('price');
```

{% endcode %}
{% endtab %}
{% endtabs %}

{% hint style="info" %}
Ordering direction is strict: only `ASC` and `DESC` are accepted. Invalid values are normalised to the method default and surfaced as a warning in `explain()`.
{% endhint %}

***

## 🔍 Search

{% tabs %}
{% tab title="Basic search" %}
{% code title="Controller" lineNumbers="true" %}

```php
// Set the search term — sanitised with sanitize_text_field()
$q = Quartermaster::posts('post')
    ->search(get_query_var('s'))
    ->status('publish')
    ->paged(12);

// null/empty values are ignored — no `s` arg is set
$q = Quartermaster::posts('post')->search(null);
```

{% endcode %}
{% endtab %}

{% tab title="Relevanssi" %}
{% code title="Controller" lineNumbers="true" %}

```php
// Set the search term and enable Relevanssi integration
$q = Quartermaster::posts('post')
    ->relevanssi(get_query_var('s'))
    ->status('publish')
    ->paged(12);

// Sets both `s` and `relevanssi = true` so the Relevanssi plugin intercepts the query.
// Empty/null values are ignored — neither `s` nor `relevanssi` are set.
```

{% endcode %}

{% hint style="warning" %}
`relevanssi()` requires the [Relevanssi](https://www.relevanssi.com/) plugin to be active. Without it, the `relevanssi` arg is ignored by WordPress and the query falls back to default search.
{% endhint %}
{% endtab %}
{% endtabs %}

***

## 🏷️ Terms — Quick Start

Quartermaster also provides a fluent builder for `WP_Term_Query` via `Quartermaster::terms()`.

{% tabs %}
{% tab title="Basic" %}
{% code title="Controller" lineNumbers="true" %}

```php
$terms = Quartermaster::terms('category')
    ->hideEmpty()
    ->orderBy('name')
    ->limit(20)
    ->get();
```

{% endcode %}
{% endtab %}

{% tab title="Scoped to a post" %}
{% code title="Controller" lineNumbers="true" %}

```php
// Terms attached to a specific post
$tags = Quartermaster::terms('post_tag')
    ->objectIds($post->ID)
    ->get();
```

{% endcode %}
{% endtab %}

{% tab title="Scoped to a post type" %}
{% code title="Controller" lineNumbers="true" %}

```php
// Only categories actually used by published 'event' posts
$categories = Quartermaster::terms('category')
    ->forPostType('event')
    ->hideEmpty()
    ->orderBy('name')
    ->get();
```

{% endcode %}

{% hint style="info" %}
`forPostType()` bridges a gap in `WP_Term_Query` — WordPress doesn't natively support scoping terms by post type. Under the hood it queries published post IDs for the given type and passes them as `object_ids`.
{% endhint %}
{% endtab %}

{% tab title="Hierarchical" %}
{% code title="Controller" lineNumbers="true" %}

```php
// All descendants of a parent term
$children = Quartermaster::terms('category')
    ->childOf(5)
    ->excludeTree(12)
    ->get();

// Leaf categories only (no children)
$leaves = Quartermaster::terms('category')
    ->childless()
    ->get();

// Direct children of a parent
$directChildren = Quartermaster::terms('category')
    ->parent(5)
    ->get();
```

{% endcode %}
{% endtab %}

{% tab title="Find by slug/name" %}
{% code title="Controller" lineNumbers="true" %}

```php
// Find terms by slug
$genres = Quartermaster::terms('genre')
    ->slug(['rock', 'jazz'])
    ->hideEmpty(false)
    ->get();

// Find terms by name
$terms = Quartermaster::terms('category')
    ->name('Tutorials')
    ->get();

// Return IDs only
$ids = Quartermaster::terms('category')
    ->fields('ids')
    ->get();
```

{% endcode %}
{% endtab %}

{% tab title="🌲 Timber" %}
{% code title="Controller" lineNumbers="true" %}

```php
$timberTerms = Quartermaster::terms('category')
    ->hideEmpty()
    ->orderBy('name')
    ->timber();

// Returns array of Timber\Term objects
```

{% endcode %}

With the [term-filter fix](https://github.com/pressgang-wp/pressgang-quartermaster/pull/1) (commit `49d5787` or a descendant), the term `timber()` terminal fetches through WordPress `get_terms()` and converts each term individually. It preserves final result filters, plugin ordering and empty results, while retaining Timber class mapping. Scalar field projections retain their values and keys; invalid taxonomies and unsupported count results raise `RuntimeException`.

Check the installed dependency revision before relying on this behaviour. Older versions query through Timber directly and may bypass a plugin's final term ordering filter. Avoid `Timber::get_terms([])` as a conversion shortcut: on Timber 2.5.1 an empty list is interpreted as a query.
{% endtab %}

{% tab title="Pagination" %}
{% code title="Controller" lineNumbers="true" %}

```php
// Paginate terms manually
$terms = Quartermaster::terms('category')
    ->page(2, 10)   // page 2, 10 per page
    ->get();

// Or set limit and offset directly
$terms = Quartermaster::terms('category')
    ->limit(10)
    ->offset(20)
    ->get();
```

{% endcode %}
{% endtab %}
{% endtabs %}

***

## 🔗 Binding Query Vars

One of Quartermaster's most powerful features — bind URL query parameters directly to query clauses. Perfect for archive filtering, search pages, and faceted navigation.

{% hint style="info" %}
Nothing reads query vars unless you explicitly call `bindQueryVars()`. No smuggling, no hidden defaults.
{% endhint %}

```mermaid
graph LR
    A["🌐 URL query vars"] --> B["bindQueryVars()"]
    B --> C["🔗 Bind / Binder"]
    C --> D["⚓ Quartermaster args"]
```

Two styles are supported — both compile to the same binding map.

{% tabs %}
{% tab title="Map style (Bind::\*)" %}
{% code title="Controller" lineNumbers="true" %}

```php
use PressGang\Quartermaster\Bindings\Bind;
use PressGang\Quartermaster\Quartermaster;

$q = Quartermaster::posts('route')->bindQueryVars([
    'paged'          => Bind::paged(),
    'orderby'        => Bind::orderBy('date', 'DESC', ['title' => 'ASC']),
    'shape'          => Bind::tax('route_shape'),
    'difficulty'     => Bind::tax('route_difficulty'),
    'min_distance'   => Bind::metaNum('distance_miles', '>='),
    'max_distance'   => Bind::metaNum('distance_miles', '<='),
    'search'         => Bind::search(),
]);
```

{% endcode %}
{% endtab %}

{% tab title="Fluent style (Binder)" %}
{% code title="Controller" lineNumbers="true" %}

```php
use PressGang\Quartermaster\Bindings\Binder;
use PressGang\Quartermaster\Quartermaster;

$q = Quartermaster::posts('route')->bindQueryVars(function (Binder $b): void {
    $b->paged();
    $b->orderBy('orderby', 'date', 'DESC', ['title' => 'ASC']);
    $b->tax('district');                           // district -> district
    $b->tax('shape', 'route_shape');               // shape -> route_shape
    $b->tax('difficulty', 'route_difficulty');
    $b->metaNum('min_distance')->to('distance_miles', '>=');
    $b->metaNum('max_distance')->to('distance_miles', '<=');
    $b->search('search');
});
```

{% endcode %}

{% hint style="info" %}
If no taxonomy is provided, Binder assumes the taxonomy name matches the query var key.
{% endhint %}
{% endtab %}
{% endtabs %}

### Available bindings

| Binding              | Purpose                                                                      | Empty handling            |
| -------------------- | ---------------------------------------------------------------------------- | ------------------------- |
| `Bind::paged()`      | Pagination from query var                                                    | Skips if ≤ 0              |
| `Bind::tax()`        | Taxonomy filter                                                              | Skips if empty or null    |
| `Bind::orderBy()`    | Sort field + direction with per-field overrides                              | Falls back to default     |
| `Bind::metaNum()`    | Numeric meta comparison                                                      | Skips if null or empty    |
| `Bind::search()`     | Search query                                                                 | Skips if empty; sanitised |
| `Bind::relevanssi()` | Relevanssi-aware search (requires [Relevanssi](https://www.relevanssi.com/)) | Skips if empty; sanitised |

{% hint style="success" %}
Every binding attempt is logged in `explain()` output — including whether it was applied, skipped, and why. Bound values are redacted for safety; only type shapes are shown.
{% endhint %}

***

## 🗓️ Common Pattern: Meta Date vs Today

Filtering by a meta date (e.g. upcoming vs past events) is a very common WordPress pattern:

{% code title="Controller" lineNumbers="true" %}

```php
$isArchive = isset($_GET['archive']);

$q = Quartermaster::posts()
    ->postType('event')
    ->status('publish')
    ->whereMetaDate('start', $isArchive ? '<' : '>=')
    ->orderByMeta('start', $isArchive ? 'DESC' : 'ASC');
```

{% endcode %}

This keeps intent explicit — `whereMetaDate(...)` adds a `meta_query` DATE clause while `orderByMeta(...)` controls ordering separately. No hidden assumptions. No barnacles. ⚓

***

## 🔀 Conditional Queries

`when()`, `unless()`, and `tap()` keep fluent chains readable without introducing magic or hidden state. None of them read globals or add defaults.

{% tabs %}
{% tab title="when()" %}
Runs a closure when the condition is true:

{% code title="Controller" lineNumbers="true" %}

```php
$q = Quartermaster::posts('event')
    ->when($isArchive, fn ($q) =>
        $q->whereMetaDate('start', '<')->orderByMeta('start', 'DESC')
    )
    ->when(! $isArchive, fn ($q) =>
        $q->whereMetaDate('start', '>=')->orderByMeta('start', 'ASC')
    );
```

{% endcode %}

Or with an else clause — cleaner when conditions are binary:

{% code title="Controller" %}

```php
$q = Quartermaster::posts('event')
    ->when(
        $isArchive,
        fn ($q) => $q->orderBy('date', 'DESC'),
        fn ($q) => $q->orderBy('date', 'ASC'),
    );
```

{% endcode %}
{% endtab %}

{% tab title="unless()" %}
Inverse of `when()` — `unless($x)` is `when(!$x)`:

{% code title="Controller" lineNumbers="true" %}

```php
$q = Quartermaster::posts('event')
    ->unless($isArchive, fn ($q) =>
        $q->whereMetaDate('start', '>=')->orderByMeta('start', 'ASC')
    );
```

{% endcode %}
{% endtab %}

{% tab title="tap()" %}
Always runs a closure, for builder-level logic without breaking the chain:

{% code title="Controller" lineNumbers="true" %}

```php
$q = Quartermaster::posts('event')
    ->tap(function ($q) use ($debug) {
        if ($debug) {
            $q->noFoundRows();
        }
    })
    ->when(!empty($authorIds), fn ($q) => $q->whereAuthorIn($authorIds))
    ->status('publish')
    ->paged(25);
```

{% endcode %}
{% endtab %}
{% endtabs %}

{% hint style="info" %}
All three are recorded in `explain()` for debuggability.
{% endhint %}

***

## 🔌 Macros

Macros let you register project-specific fluent methods without bloating the core API. They are opt-in — use them for patterns that repeat across your project.

{% code title="functions.php or Service Provider" lineNumbers="true" %}

```php
Quartermaster::macro('orderByMenuOrder', function (string $dir = 'ASC') {
    return $this->orderBy('menu_order', $dir);
});

Quartermaster::macro('published', function () {
    return $this->status('publish');
});

// Now use them anywhere
$posts = Quartermaster::posts('page')
    ->published()
    ->orderByMenuOrder()
    ->get();
```

{% endcode %}

{% hint style="warning" %}
Macros should call existing Quartermaster methods — avoid mutating internal args directly. Macro invocations are recorded in `explain()` as `macro:<name>` for debuggability.
{% endhint %}

Both builders (`Quartermaster` and `TermsBuilder`) support macros independently. Use `flushMacros()` in tests to clean up.

***

## 🪝 Escape Hatch: `tapArgs()`

When you need to set an arg that Quartermaster doesn't have a dedicated method for, use `tapArgs()` to manipulate the raw args array while preserving the fluent chain:

{% code title="Controller" lineNumbers="true" %}

```php
$q = Quartermaster::posts('event')
    ->status('publish')
    ->tapArgs(fn (array $args) => array_merge($args, [
        'cache_results' => false,
        'suppress_filters' => true,
    ]));
```

{% endcode %}

{% hint style="info" %}
The callback receives the current args array and must return the full replacement array. `tapArgs()` is recorded in `explain()` for debuggability.
{% endhint %}

***

## 🔍 Debugging and Introspection

{% tabs %}
{% tab title="toArgs()" %}
Inspect the generated WordPress args array at any point in the chain:

{% code title="Controller" %}

```php
$args = Quartermaster::posts()
    ->postType('event')
    ->status('publish')
    ->whereMeta('featured', '1')
    ->toArgs();

// array(3) {
//   'post_type'   => 'event',
//   'post_status' => 'publish',
//   'meta_query'  => [['key' => 'featured', 'value' => '1', 'compare' => '=']]
// }
```

{% endcode %}
{% endtab %}

{% tab title="explain()" %}
Inspect args **plus** the full call history, warnings, and binding log:

{% code title="Controller" %}

```php
$info = Quartermaster::posts()
    ->postType('event')
    ->orderBy('meta_value')  // ← no meta_key set!
    ->explain();

// $info['args']     → final WP_Query args
// $info['applied']  → [['name' => 'postType', 'params' => ['event']], ...]
// $info['warnings'] → ['Using orderby=meta_value without meta_key will produce unreliable ordering.']
```

{% endcode %}

When `bindQueryVars()` has been used, `explain()` also includes a `bindings` array showing each binding attempt — whether it was applied, skipped (and why), with values safely redacted.
{% endtab %}
{% endtabs %}

{% hint style="success" %}
`explain()` is perfect for code reviews, debugging, and making sure your queries do exactly what you intend. Think of it as the ship's manifest — every item accounted for. 🧭
{% endhint %}

### Smart warnings

Quartermaster automatically detects common gotchas:

| Warning             | Trigger                                         |
| ------------------- | ----------------------------------------------- |
| Unreliable ordering | `orderby=meta_value` without `meta_key` set     |
| Pagination ignored  | `posts_per_page=-1` with `paged` set            |
| Hidden empty terms  | `hide_empty` not explicitly set on term queries |

***

## 📖 Method Reference

<details>

<summary><strong>🚢 Bootstrap</strong></summary>

| Method                               | Description                       |
| ------------------------------------ | --------------------------------- |
| `Quartermaster::posts($postType?)`   | Start a new post query builder    |
| `Quartermaster::terms($taxonomy?)`   | Start a new term query builder    |
| `Quartermaster::prepare($postType?)` | Compatibility alias for `posts()` |

</details>

<details>

<summary><strong>📌 Post Constraints</strong></summary>

| Method                    | Sets              |
| ------------------------- | ----------------- |
| `postType(string\|array)` | `post_type`       |
| `status(string)`          | `post_status`     |
| `whereId(int)`            | `p`               |
| `whereInIds(array)`       | `post__in`        |
| `excludeIds(array)`       | `post__not_in`    |
| `whereParent(int)`        | `post_parent`     |
| `whereParentIn(array)`    | `post_parent__in` |
| `whereAuthor(int)`        | `author`          |
| `whereAuthorIn(array)`    | `author__in`      |
| `whereAuthorNotIn(array)` | `author__not_in`  |

</details>

<details>

<summary><strong>🔎 Meta Queries</strong></summary>

| Method                                              | Description                           |
| --------------------------------------------------- | ------------------------------------- |
| `whereMeta($key, $value, $compare?, $type?)`        | AND meta clause                       |
| `orWhereMeta($key, $value, $compare?, $type?)`      | OR meta clause                        |
| `whereMetaNot($key, $value)`                        | Exclude by value (handles NOT EXISTS) |
| `whereMetaDate($key, $operator, $value?, $format?)` | Date comparison (defaults to today)   |
| `whereMetaExists($key)`                             | Key exists check                      |
| `whereMetaNotExists($key)`                          | Key does not exist check              |
| `wherePageTemplate($template)`                      | Match pages by `_wp_page_template`    |
| `whereMetaLikeAny($key, $values)`                   | Match serialized ACF fields           |

</details>

<details>

<summary><strong>🏷️ Taxonomy Queries</strong></summary>

| Method                                               | Description                                |
| ---------------------------------------------------- | ------------------------------------------ |
| `whereTax($taxonomy, $terms, $field?, $operator?)`   | Taxonomy query clause (AND)                |
| `orWhereTax($taxonomy, $terms, $field?, $operator?)` | Taxonomy query clause under an OR relation |

Field defaults to `slug`. Operator defaults to `IN`. Multiple `whereTax()` calls produce an AND relation; the first `orWhereTax()` switches it to OR. Null or empty `$terms` leave the builder unchanged, so optional filters can be passed directly (`->whereTax('topic', $topic ?: null)`).

</details>

<details>

<summary><strong>📅 Date Queries</strong></summary>

| Method                                        | Description                      |
| --------------------------------------------- | -------------------------------- |
| `whereDate(array)`                            | Raw WordPress date\_query clause |
| `whereDateAfter(string\|array, $inclusive?)`  | Published after date             |
| `whereDateBefore(string\|array, $inclusive?)` | Published before date            |

</details>

<details>

<summary><strong>↕️ Ordering</strong></summary>

| Method                                                         | Description                   |
| -------------------------------------------------------------- | ----------------------------- |
| `orderBy($orderby, $order?)`                                   | Set order field and direction |
| `orderByAsc($orderby)` / `orderByDesc($orderby)`               | Shorthand                     |
| `orderByMeta($key, $order?, $type?)`                           | Order by meta value           |
| `orderByMetaAsc($key)` / `orderByMetaDesc($key)`               | Shorthand meta ordering       |
| `orderByMetaNumeric($key, $order?)`                            | Order by numeric meta         |
| `orderByMetaNumericAsc($key)` / `orderByMetaNumericDesc($key)` | Shorthand                     |

</details>

<details>

<summary><strong>🔍 Search</strong></summary>

| Method                     | Description                                                                                            |
| -------------------------- | ------------------------------------------------------------------------------------------------------ |
| `search(string\|null)`     | Set search term (`s`); sanitised, null/empty ignored                                                   |
| `relevanssi(string\|null)` | Search with Relevanssi (`s` + `relevanssi = true`); requires [Relevanssi](https://www.relevanssi.com/) |

</details>

<details>

<summary><strong>📄 Pagination and Performance</strong></summary>

| Method                     | Description                                      |
| -------------------------- | ------------------------------------------------ |
| `paged($perPage?, $page?)` | Paginate (reads current page from query vars)    |
| `limit(int)`               | Fixed result count                               |
| `all()`                    | Fetch all (`posts_per_page=-1`, `nopaging=true`) |
| `noFoundRows()`            | Skip `SQL_CALC_FOUND_ROWS`                       |
| `idsOnly()`                | Return IDs only (`fields='ids'`)                 |
| `withMetaCache(bool)`      | Toggle meta cache priming                        |
| `withTermCache(bool)`      | Toggle term cache priming                        |
| `ignoreStickyPosts()`      | Prevent sticky posts being prepended             |

</details>

<details>

<summary><strong>🔀 Conditionals and Hooks</strong></summary>

| Method                              | Description           |
| ----------------------------------- | --------------------- |
| `when($condition, $then, $else?)`   | Conditional closure   |
| `unless($condition, $then, $else?)` | Inverse conditional   |
| `tap($callback)`                    | Always-run closure    |
| `tapArgs($callback)`                | Raw args manipulation |

</details>

<details>

<summary><strong>🔌 Macros</strong></summary>

| Method                    | Description                   |
| ------------------------- | ----------------------------- |
| `macro($name, $callable)` | Register a named macro        |
| `hasMacro($name)`         | Check if macro is registered  |
| `flushMacros()`           | Remove all macros (for tests) |

</details>

<details>

<summary><strong>🔗 Query-Var Binding</strong></summary>

| Method                                       | Description                                                                          |
| -------------------------------------------- | ------------------------------------------------------------------------------------ |
| `bindQueryVars($bindings, $source?)`         | Bind URL query vars to query clauses                                                 |
| `Bind::paged()`                              | Pagination binding                                                                   |
| `Bind::tax($taxonomy, $field?, $operator?)`  | Taxonomy binding                                                                     |
| `Bind::orderBy($default, $dir, $overrides?)` | Order binding with per-field overrides                                               |
| `Bind::metaNum($key, $compare)`              | Numeric meta binding                                                                 |
| `Bind::search()`                             | Search term binding                                                                  |
| `Bind::relevanssi()`                         | Relevanssi-aware search binding (requires [Relevanssi](https://www.relevanssi.com/)) |

</details>

<details>

<summary><strong>🔍 Introspection</strong></summary>

| Method      | Returns                                     |
| ----------- | ------------------------------------------- |
| `toArgs()`  | `array<string, mixed>` — raw WP\_Query args |
| `explain()` | `array{args, applied, warnings, bindings?}` |

</details>

<details>

<summary><strong>🏁 Terminals</strong></summary>

| Method            | Returns                                                            |
| ----------------- | ------------------------------------------------------------------ |
| `get()`           | `WP_Post[]` — execute and return posts                             |
| `toArray()`       | `array` — smart Timber/WP detection                                |
| `first()`         | `Timber\Post\|WP_Post\|null` — first match, builder unchanged      |
| `wpQuery()`       | `WP_Query` — full query object                                     |
| `timber()`        | `Timber\PostQuery` — Timber post collection                        |
| `applyTo($query)` | `void` — modify existing `WP_Query` in place (for `pre_get_posts`) |

</details>

<details>

<summary><strong>🏷️ Terms Builder</strong></summary>

| Method                                               | Description                                                    |
| ---------------------------------------------------- | -------------------------------------------------------------- |
| `taxonomy(string\|array)`                            | Set taxonomy                                                   |
| `objectIds(int\|array)`                              | Scope to specific post(s)                                      |
| `forPostType(string)`                                | Scope terms to a post type (via `object_ids`)                  |
| `hideEmpty(bool)`                                    | Hide/show empty terms                                          |
| `slug(string\|array)`                                | Filter by slug                                                 |
| `name(string\|array)`                                | Filter by name                                                 |
| `fields(string)`                                     | Control return fields (`ids`, `names`, `slugs`, `count`, etc.) |
| `include(array)` / `exclude(array)`                  | Include/exclude term IDs                                       |
| `excludeTree(int\|array)`                            | Exclude entire term branches                                   |
| `parent(int)`                                        | Direct children only                                           |
| `childOf(int)`                                       | All descendants                                                |
| `childless(bool)`                                    | Leaf terms only                                                |
| `search(string)`                                     | Search terms                                                   |
| `limit(int)` / `offset(int)`                         | Limit and offset                                               |
| `page(int, int)`                                     | Convenience pagination                                         |
| `orderBy(string, string)`                            | Order terms                                                    |
| `whereMeta()` / `orWhereMeta()`                      | Term meta queries                                              |
| `whereMetaExists($key)` / `whereMetaNotExists($key)` | Valueless term metadata existence checks                       |
| `get()`                                              | Execute and return terms                                       |
| `timber()`                                           | Execute and return `Timber\Term[]`                             |

</details>

***

## 🔗 Links

* [GitHub: pressgang-wp/quartermaster](https://github.com/pressgang-wp/pressgang-quartermaster)
* [Packagist: pressgang-wp/quartermaster](https://packagist.org/packages/pressgang-wp/quartermaster)

Smooth seas and predictable queries. Happy sailing. ⚓🚢

## Alternative serialized relationship fields

```php
$query = Quartermaster::posts('research-project')
    ->whereMetaLikeAny('other_arc_staff', [$post->ID])
    ->orWhereMetaLikeAny('arc_lead', [$post->ID]);
```

`orWhereMetaLikeAny()` accepts raw values and shares the existing serialized-string quoting. Empty arrays leave the query unchanged. Like `orWhereMeta()`, it forces the root meta relation to OR, including previous clauses; later `whereMeta()` calls retain that relation. For `required AND (A OR B)`, use an explicitly nested seed array. Prefer seed arrays for fixed arguments and `tapArgs()` for transformations of an existing query.

When migrating search queries, compare empty searches as well as populated ones. `relevanssi('')` leaves arguments unchanged: it does not enable Relevanssi or set `s`. Preserve explicit empty-search seed arguments if the plugin changes results or ordering for that state. `paged(paged: $page)` also sets the WordPress page size.

## Explicit empty input

```php
$publications = Quartermaster::posts('publication')
    ->relevanssi($search, allowEmpty: true)
    ->paged(paged: $page);

$staff = Quartermaster::posts('staff-member')
    ->all()
    ->whereTax('research-team', $teamIds, 'term_id', allowEmpty: true)
    ->excludeIds([$post->ID]);
```

Use the optional `allowEmpty` flag when emptiness is meaningful. Relevanssi sets an explicit sanitized empty `s` and enables its flag, replacing any prior search; null leaves the search unchanged. Both `Binder::relevanssi()` and `Bind::relevanssi()` support the option and skip missing/null query-var values unless an explicit default is supplied.

For `whereTax()` and `orWhereTax()`, the option retains an empty native taxonomy clause. Empty `IN` returns no posts; empty `NOT IN` excludes nothing. This removes the need for raw arrays for required empty relationships. Optional filters retain their existing skip-empty behaviour when the flag is omitted. No plugin is installed or index managed by these helpers.

## Fluent term metadata ordering

```php
$themes = Quartermaster::terms('research-theme')
    ->hideEmpty(false)
    ->orderByMetaNumeric('sort_order')
    ->timber();
```

Terms now support `orderByMeta($key, $order = 'ASC', $metaType = 'CHAR')` and `orderByMetaNumeric($key, $order = 'ASC')`, matching the post-builder vocabulary. These replace raw `meta_key`/`orderby` seed arguments. Other constraints remain intact, and the normal WordPress-backed terminal preserves final term filters. WordPress excludes terms without the selected metadata key; verify membership when replacing an in-memory sort. Invalid directions use the existing ASC fallback and warning.

### Search binding defaults and input handling

```php
$query = Quartermaster::posts('research-project')
    ->bindQueryVars(function (Binder $bindings): void {
        $bindings->relevanssi('project-search', allowEmpty: true, default: '');
    });
```

`Bind::search()` / `Binder::search()` accept `?string $default = null`; `Bind::relevanssi()` / `Binder::relevanssi()` accept it after `allowEmpty`. A default applies only to missing/null values. An explicit empty string never uses the default. Without a default, missing/null values leave the query unchanged. Use `allowEmpty: true, default: ''` to enable Relevanssi even without a search term. This deliberately opts into an empty search; it does not install or index Relevanssi.

Scalar inputs are converted to strings; arrays, objects and resources are skipped without applying the default or replacing an existing search. Sanitization belongs to the search builder: `sanitize_text_field()` in WordPress, trimming otherwise. Do not duplicate it around bindings. Bindings do not execute queries or escape HTML output; keep contextual escaping in templates.

Never automatically URL-decode query variables. PHP already decodes GET values; decoding again corrupts literal plus signs such as `C++`. WordPress sanitization strips percent-encoded characters; Quartermaster retains that established behaviour. See [PHP's urldecode guidance](https://www.php.net/manual/en/function.urldecode.php) and [WordPress sanitization](https://developer.wordpress.org/reference/functions/sanitize_text_field/). Use custom preparation only for a documented source encoding, before sanitization.

### Term metadata existence

```php
$terms = Quartermaster::terms('category')
    ->whereMetaExists('image')
    ->whereMeta('image', '', '!=')
    ->get();
```

Use `whereMetaNotExists('hidden')` to require an absent key. Both helpers omit `value` and `type`, matching the post builder. A generic `whereMeta()` call with `EXISTS` and a value (including null) does not have the same WordPress semantics.


# Capstan

A WP-CLI command package that scaffolds, configures, and packages PressGang WordPress themes. Dry-run by default — preview every plan before it writes a byte.

Aboard ship, the capstan is the winch that hauls the anchor and gets the vessel underway. In your project, Capstan gets a new PressGang theme underway — WordPress core, parent theme, child theme, one command. ⚓

{% hint style="success" %}
Capstan installs as a **global WP-CLI package**, so it's available *before* any project exists — exactly when you need scaffolding. Nothing to `composer require` into a project that isn't there yet.
{% endhint %}

## 📦 Install

{% code title="Terminal" %}

```bash
wp package install https://github.com/pressgang-wp/pressgang-capstan.git
```

{% endcode %}

**Requirements:** PHP 8.3+, [WP-CLI](https://wp-cli.org/), [Composer](https://getcomposer.org/)

{% hint style="warning" %}
WP-CLI's shorthand package index (`wp package install pressgang-wp/capstan`) is deprecated and may not resolve the package — use the Git URL above.
{% endhint %}

***

## 🧰 Commands

| Command                      | Description                                                         |
| ---------------------------- | ------------------------------------------------------------------- |
| `wp capstan new`             | Scaffold a full PressGang project — core, parent, child theme       |
| `wp capstan make child`      | Scaffold a child theme into an existing WordPress install           |
| `wp capstan make cpt`        | Scaffold a custom post type entry in `config/custom-post-types.php` |
| `wp capstan make block`      | Scaffold an ACF block — block.json, Twig stub, config registration  |
| `wp capstan make controller` | Scaffold a controller with a documented `$context_getters` manifest |
| `wp capstan resolve <url>`   | Template hierarchy candidates → resolved controller for a URL       |
| `wp capstan context <Ctrl>`  | A controller's context manifest and getters; `--add` publishes keys |
| `wp capstan config dump`     | The resolved PressGang configuration the theme boots with           |
| `wp capstan snippets`        | Registered snippets, their resolved classes and args                |
| `wp capstan doctor`          | Deterministic theme configuration health checks                     |
| `wp capstan theme package`   | Build a WordPress-uploadable ZIP from a theme directory             |
| `wp capstan about`           | Capstan version, PHP version, WordPress root detection              |

## 🧭 Introspection

PressGang's conventions are invisible by design — introspection makes them verifiable:

{% code title="Terminal" %}

```bash
wp capstan resolve /events/
# Hierarchy candidates, the controller each would infer, and the winner:
# dispatch — EventsController renders archive-event.twig (via "archive-event")

wp capstan context FrontPage
# The $context_getters manifest, each getter's declaring class, and
# theme getters not yet published in the manifest.

wp capstan context FrontPage --add=news,events --force
# Publishes keys into the manifest — explicit selection, theme-owned
# files only, lint-checked before the file is replaced.

wp capstan doctor
# 11 deterministic checks: autoload, namespace, snippet/provider/route
# classes, shadowed page templates, legacy v1 boot files...
```

{% endcode %}

These answer the questions agents (and new crew) ask most: *which controller handles this URL, what data does the template get, and is the rigging sound?* [Bosun](/ecosystem/bosun)-composed guidelines teach agents these recipes.

`doctor` stays runtime-only: deterministic, fast, and heuristic-free. It should not absorb PHPStan-style static analysis, type checks, or convention inference. Use `composer phpstan` for static analysis and [Shakedown](/ecosystem/shakedown) for browser/runtime assertions.

`wp capstan check` is deliberately unbuilt. If it lands later, it must be a thin orchestrator that shells out to the target theme's `composer check`, runs `wp capstan doctor`, and reports both results. It must not re-implement, configure, or wrap PHPStan itself. Do not build it until at least one child theme has adopted a working `composer check` for Capstan to call.

{% hint style="info" %}
**Dry-run by default.** Every scaffolding command prints its execution plan first and only writes when you re-run with `--force`. Review the charts before you sail. 🗺️
{% endhint %}

***

## 🚀 New Project

One command takes you from empty directory to running PressGang project: downloads WordPress core, installs the parent theme via Composer, scaffolds a child theme, and optionally configures ACF Pro as an MU-plugin.

{% tabs %}
{% tab title="Quick start" %}
{% code title="Terminal" %}

```bash
# Preview the execution plan
wp capstan new my-theme --dbuser=root

# Looks good? Execute it
wp capstan new my-theme --dbuser=root --force

# With ACF Pro wired as an MU-plugin
wp capstan new my-theme --dbuser=root --acf --force
```

{% endcode %}
{% endtab %}

{% tab title="Full customisation" %}
{% code title="Terminal" %}

```bash
wp capstan new my-theme \
  --dbname=mytheme --dbuser=wp --dbpass=secret --dbhost=localhost \
  --url=http://mytheme.local --title="My Theme" \
  --admin-user=admin --admin-email=dev@example.com \
  --namespace=MyTheme --acf --force
```

{% endcode %}
{% endtab %}
{% endtabs %}

{% hint style="info" %}
With `--acf`, the root `composer.json` and MU-plugin loader are written, but ACF Pro itself is not downloaded — it needs licence authentication. The summary output lists the steps to complete the install.
{% endhint %}

***

## 🧒 Child Theme

Scaffold a PressGang child theme into an existing WordPress installation — PSR-4 `src/`, `config/` registration, Composer wiring, all from the maintained starter template.

{% code title="Terminal" %}

```bash
# Preview what would be generated
wp capstan make child my-theme

# Generate it
wp capstan make child my-theme --force

# Custom display name and namespace
wp capstan make child my-theme --name="My Theme" --namespace=MyTheme --force

# Explicit target path
wp capstan make child my-theme --path=/srv/www/wp-content/themes --force
```

{% endcode %}

After a scaffold's dependencies land, Capstan automatically runs [Bosun](/ecosystem/bosun) (when installed) — so a freshly launched theme starts life with its AI crew already briefed. 🧭

***

## 📦 Theme Packaging

Build a ZIP ready for **Appearance → Themes → Upload Theme**. Build artifacts — `.git/`, `node_modules/`, editor directories, `.env`, dev config — are excluded automatically.

{% code title="Terminal" %}

```bash
# Preview what would be packaged (from inside a theme directory)
wp capstan theme package

# Create the zip
wp capstan theme package --force

# Package a specific directory, custom output path
wp capstan theme package /path/to/my-theme --output=release/my-theme.zip --force
```

{% endcode %}

The ZIP lands alongside the theme directory by default (e.g. `themes/my-theme.zip`).

***

## 🧠 Philosophy

|     | Principle                                                  |
| --- | ---------------------------------------------------------- |
| 🗺️ | **Dry-run by default** — always preview before writing     |
| 🎯  | **Explicit over implicit** — no hidden global state        |
| 🔍  | **Minimal abstractions, maximum inspectability**           |
| 🌍  | **Global by design** — available before any project exists |

## 🗺️ Roadmap

Capstan's [README roadmap](https://github.com/pressgang-wp/pressgang-capstan#roadmap) is the single source of truth for planned commands. Currently charted:

|     | Planned            | Purpose                     |
| --- | ------------------ | --------------------------- |
| 🖼️ | `theme screenshot` | Generate a theme screenshot |

***

## 🔗 Links

* [GitHub: pressgang-wp/pressgang-capstan](https://github.com/pressgang-wp/pressgang-capstan)

Heave away, and get her underway. 🛞⚓


# Bosun

A WP-CLI package that composes AI agent guidelines and skills for PressGang themes — generated from what each theme actually has installed and enabled.

Aboard ship, the bosun pipes the captain's orders to the crew. In your project, Bosun pipes PressGang's conventions to the AI crew — so every agent that comes aboard already knows the ropes. ⚓

PressGang's best features are its most hidden: [template routing](/template-routing), context getter manifests, config-driven registration, [Quartermaster](/ecosystem/quartermaster), the snippets library. By design they leave barely a trace in a child theme's code — wonderful for developers who know the ship, invisible to agents who don't. Left unbriefed, an agent writes stub files, hand-rolled `WP_Query` arrays, and `functions.php` hooks: working code that misses everything that makes PressGang worth sailing.

Bosun is the briefing.

## 📦 Install

{% code title="Terminal" %}

```bash
wp package install https://github.com/pressgang-wp/pressgang-bosun.git
```

{% endcode %}

One global install briefs every theme on the machine.

## 🚀 Usage

{% code title="Terminal" %}

```bash
wp bosun install    # compose CLAUDE.md + AGENTS.md for the active child theme
wp bosun update     # recompose after composer updates (idempotent)
```

{% endcode %}

That's it — all hands briefed. Commit the generated files so agents on machines without Bosun still get the briefing.

## 🧠 What Gets Composed

The document always opens with an **inventory of reality** — installed package versions with lock refs, and the feature opt-ins detected in `config/` — so agents reason about what the theme actually runs, not the ecosystem's newest ideas:

* Guidance for [Template Routing](/template-routing) only comes aboard when `config/service-providers.php` registers the provider.
* [ACF value transformation](/acf-values) guidance only comes aboard when `config/timber.php` sets `'transform_acf_values' => true`.
* [Quartermaster](/ecosystem/quartermaster) guidance appears only when the package is installed — along with a pointer to its machine-readable API index (`docs/api-index.json`, every method signature and the WP args it sets).
* Testing guidance tells agents to prefer `composer check` when a theme provides it, and otherwise run the theme's documented test and static-analysis commands separately.
* Skills (Agent Skills format) install to `.claude/skills/` — including a v1 → v2 migration skill that appears **only** on themes still booting through PressGang v1, and disappears once they're migrated.

## 🧩 Where Guidance Comes From

Three tiers, later tiers overriding earlier ones:

|    | Tier            | Location                                                        |
| -- | --------------- | --------------------------------------------------------------- |
| 📦 | Package-shipped | `{package}/resources/bosun/guidelines/**.md`                    |
| ⚓  | Bosun built-ins | a frozen baseline for packages that predate shipped fragments   |
| 🏠 | Theme-local     | `{theme}/.ai/guidelines/**.md` — your house rules and overrides |

{% hint style="success" %}
**Bosun never clobbers your files.** It owns only the region between `<!-- bosun:start -->` and `<!-- bosun:end -->`. A hand-written `CLAUDE.md` keeps every byte and gains the region appended at the end; re-runs replace the region in place.
{% endhint %}

Customise in `.ai/guidelines/` (or outside the region) — never inside the region, which is replaced on every run.

## ⚓ With Capstan

[Capstan](/ecosystem/capstan) scaffolds run `wp bosun install` automatically after a new theme's dependencies land, so a freshly launched theme starts life with its crew already briefed.

***

Source & issues: [pressgang-wp/pressgang-bosun](https://github.com/pressgang-wp/pressgang-bosun)


# Muster

Describe the content your WordPress site needs, in code. Run it as often as you like and get the same site every time — WordPress-native, no ORM, no lock-in.

Aboard ship, a muster assembles the crew and accounts for every hand. In a WordPress project, [Muster](https://github.com/pressgang-wp/pressgang-muster) assembles content: posts, pages, terms, users, options, comments, menus, and media — created through WordPress and plugin APIs, and accounted for on every run.

Muster is a **WordPress-native toolkit for deterministic content provisioning and development fixtures**. No Models, no ORM, no mapping of application objects onto `wp_posts` and `wp_postmeta`. 🧭

{% hint style="success" %}
Muster ships as a **standalone package**. It does **not** depend on the PressGang theme framework — you can use it in any WordPress project. Its only requirements are PHP and FakerPHP.
{% endhint %}

***

## 🌱 Why seed at all?

Most WordPress teams don't. A developer clones the repo, runs it, and gets an empty site — then spends an afternoon clicking content into wp-admin so there's something to look at. What they build isn't what anyone else has.

<table><thead><tr><th width="250">What most teams do today</th><th>What it costs</th></tr></thead><tbody><tr><td>Click test content in by hand</td><td>Nobody else has your content, and it vanishes on the next reset. The awkward states — a 90-character title, a missing hero image, an empty repeater — are exactly the ones nobody builds by hand.</td></tr><tr><td>Copy the production database down</td><td>Puts real customer data on developer laptops. Gigabytes to move, and stale within a week.</td></tr><tr><td>Share a <code>.sql</code> dump or WXR export</td><td>Opaque and unreviewable — nobody can see in a pull request what changed, and it drifts away from the code it exists to support.</td></tr><tr><td>Work against an empty site</td><td>The bugs your editors hit don't appear until an editor hits them.</td></tr></tbody></table>

**Seeding replaces all four with a PHP class you commit.** It says "this site has an About page, five articles, and a main menu" — and one command makes it so, on any machine, as many times as you like.

That pays off the moment you have:

* **A new team member** — `composer install && wp capstan seed`, and they have the real thing in a minute rather than an afternoon.
* **Tests or CI** — automated checks need content, and need the *same* content on every run.
* **Visual regression** — a screenshot diff is noise unless the content is identical every time. This is what [Shakedown](/ecosystem/shakedown) is built on.
* **Editorial edge cases** — the sparse-but-valid states where empty-link and missing-image bugs hide.

***

## ✨ Why Muster?

Seeding only helps if the seed is trustworthy. Muster makes that content **declarative** (describe the end state, not the steps), **idempotent** (re-running converges instead of duplicating), and **deterministic** (the same seed produces the same site).

|    | Benefit                          | How                                                                                                                                     |
| -- | -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| 🔁 | **Re-run it forever, safely**    | A stable logical key — not the slug — is identity, so a second run updates rather than duplicates. Rename a slug and it still resolves. |
| 🎲 | **Same seed, same site**         | Randomness and time are explicit inputs, not ambient state. Two machines, two months apart, produce identical content.                  |
| 👀 | **See it before it happens**     | Every run plans first and reports what it will create, update, keep, or delete. Conflicts stop the write.                               |
| 🔐 | **It only touches what it made** | An ownership registry means `resetOwned()` never deletes the client's real pages sitting in the same database.                          |
| ⚓  | **WordPress-native, no ORM**     | Builders write through `wp_insert_post()` and friends, so hooks fire, caches invalidate, and ACF behaves as it does in wp-admin.        |
| 👥 | **Fixtures live in code review** | A colleague can read the diff of your content in a PR — not a sentence anyone says about a `.sql` file.                                 |

{% hint style="info" %}
Muster takes useful inspiration from seeders and factories in Laravel, Rails, and other frameworks. It adapts those ideas to WordPress rather than porting their persistence models.
{% endhint %}

***

## 📦 Install

Install it in the child theme (or plugin) that owns the fixtures:

{% code title="Terminal" %}

```bash
composer require --dev pressgang-wp/muster
```

{% endcode %}

**Requirements:** PHP 8.3+, FakerPHP 1.24+, a loaded WordPress runtime when resources are persisted, and WP-CLI for the `wp capstan` commands.

A development dependency is right for local setup, CI, and disposable test environments. If a controlled non-production runtime needs Muster after a `composer install --no-dev`, install it as a regular dependency instead.

Seeders live in a top-level `muster/` directory, mapped under your composer `autoload-dev` — they are development and test fixtures, not production theme code, so they belong beside the dev-only Muster dependency, not in the shipped `src/`:

{% code title="composer.json" %}

```json
"autoload-dev": { "psr-4": { "App\\Muster\\": "muster/" } }
```

{% endcode %}

(`wp capstan make muster` scaffolds into `muster/` and prints this mapping.)

{% hint style="warning" %}
Muster is **pre-1.0**. The public API may still change between minor versions — pin an exact version if that matters to you.
{% endhint %}

***

## 🚀 Your first Muster

{% stepper %}
{% step %}

#### Describe the content

A Muster is one class with a `run()` method. Every builder needs a `key()` — the stable name Muster uses to recognise this resource on the next run.

{% code title="muster/SiteMuster.php" %}

```php
<?php

namespace App\Muster;

use PressGang\Muster\Muster;

final class SiteMuster extends Muster
{
    public function run(): void
    {
        $this->page()
            ->key('page:about')
            ->title('About us')
            ->slug('about-us')
            ->status('publish')
            ->content($this->victuals()->paragraphs(3))
            ->save();
    }
}
```

{% endcode %}
{% endstep %}

{% step %}

#### Preview it — no writes

{% code title="Terminal" %}

```bash
wp capstan seed --dry-run
```

{% endcode %}

```
Plan:
  CREATE   post   [site] page:about -> about-us
  Summary: create=1 update=0 keep=0 prune=0 conflict=0
```

{% endstep %}

{% step %}

#### Apply it

{% code title="Terminal" %}

```bash
wp capstan seed --seed=1234
```

{% endcode %}

Muster prints the plan, re-resolves WordPress state, then writes.
{% endstep %}

{% step %}

#### Run it again

{% code title="Terminal" %}

```bash
wp capstan seed --seed=1234
```

{% endcode %}

```
Plan:
  KEEP     post   [site] page:about -> about-us
  Summary: create=0 update=0 keep=1 prune=0 conflict=0
```

**No duplicate. No drift.** That's the whole idea.
{% endstep %}
{% endstepper %}

***

## 🧠 Mental model

| Concept                    | Responsibility                                                                                         |
| -------------------------- | ------------------------------------------------------------------------------------------------------ |
| `Muster`                   | The orchestration entry point. A subclass implements `run()` and describes one provisioning flow.      |
| Builders                   | Collect intent for one WordPress resource and write it through the relevant WordPress API on `save()`. |
| Logical keys               | Stable identity within one concrete Muster class, independent of mutable locators such as slugs.       |
| `Victuals`                 | A curated Faker wrapper for seeded headlines, content, names, addresses, and dates.                    |
| Fixture clock              | One immutable epoch for relative dates, independent of Faker's random seed.                            |
| Groups                     | Named callback boundaries selected by `--only`; skipped callbacks are not evaluated.                   |
| `Pattern`                  | Repeats any declaration recipe a declared number of times, with an optional pattern seed.              |
| Recipes, states, sequences | Reusable resource recipes, named transformations, and immutable cycling values.                        |
| Refs                       | Immutable save results, plus logical-key `LazyRef` handles resolved at save-time.                      |
| `RunReport`                | Ordered `create`, `update`, `keep`, `prune`, and `conflict` results for one pass.                      |
| ACF generation             | Reads `acf-json` definitions and produces minimal or populated field values.                           |

Patterns can repeat posts, terms, users, options, comments, attachments, menus, or any custom declaration implementing `PressGang\Muster\Contracts\PersistableDeclaration`.

***

## ✍️ A fuller example

{% code title="muster/SiteMuster.php" %}

```php
<?php

namespace App\Muster;

use PressGang\Muster\Muster;

final class SiteMuster extends Muster
{
    public static function defaultEpoch(): string
    {
        return '2026-01-01 09:00:00+00:00';
    }

    public function run(): void
    {
        $this->group('site-shell', function (): void {
            $about = $this->page()
                ->key('page:about')
                ->title('About us')
                ->slug('about-us')
                ->status('publish')
                ->content($this->victuals()->paragraphs(3))
                ->save();

            $this->attachment('about-hero')
                ->key('attachment:about-hero')
                ->placeholder(1200, 800, 'About us')
                ->alt('Our team at work')
                ->featuredOn($about)
                ->save();

            $this->menu('Main Menu')
                ->key('menu:main')
                ->postItem($about, 'About')
                ->link('Contact', '/contact/')
                ->location('main-menu')
                ->save();

            $this->comment($about)
                ->key('comment:about:welcome')
                ->author('Fixture Editor')
                ->email('fixtures@example.test')
                ->date($this->at('+1 day')->format('Y-m-d H:i:s'))
                ->content('Welcome to the fixture discussion.')
                ->status('approve')
                ->save();
        });

        $this->group('articles', function (): void {
            $this->pattern('article-fixtures')
                ->seed(1201)
                ->count(5)
                ->withThumbnail()
                ->build(
                    fn (int $i) => $this->content()
                        ->slug('article-' . $i)
                        ->date($this->at('-' . $i . ' weeks')->format('Y-m-d H:i:s'))
                );
        });
    }
}
```

{% endcode %}

Every write goes through WordPress-native functions such as `wp_insert_post()`, `wp_update_post()`, `wp_insert_term()`, `wp_update_user()`, and `wp_update_nav_menu_item()`. ACF values use its public `update_field()` API.

{% hint style="info" %}
**Less ceremony, same guarantees** (see [ADR 0006](https://github.com/pressgang-wp/pressgang-muster/blob/main/docs/adr/0006-seeder-authoring-ergonomics.md)):

* **Pattern rows self-key** from the pattern name and one-based index (`article-fixtures:1`, `article-fixtures:2`, …) when the recipe sets no `key()` — stable and independent of the slug, so re-runs and resets behave exactly as with a hand-written key. Call `key()` to override it.
* **`withThumbnail()`** gives each row a deterministic placeholder featured image without a hand-written after-hook.
* **A post's `status` defaults to `publish` and its `date` to the fixture epoch** on first insert — so the common row writes neither. An explicit `status()`/`date()` still wins, and the default applies to inserts only, never overwriting a field on a re-run.
* **`content($type)`** returns a post pre-filled with a generated title, body, and the ACF values `acfFor($type)` derives — the "populated content" shape in one place. Override any field afterwards; it is opt-in, so bare `post()` still writes only what you declare (what the sparse "minimal" fixtures rely on).
  {% endhint %}

<details>

<summary><strong>Declare fields as an array — <code>fill()</code></strong></summary>

Every post and term builder also accepts a **nested array** instead of chained setters. The keys are WordPress's own — the ones `wp_insert_post()` and `wp_insert_term()` already take — so there is no second vocabulary to learn:

```php
$this->post('event')->fill([
    'post_title'  => 'Launch',
    'post_name'   => 'launch',
    'post_status' => 'publish',
    'meta_input'  => ['legacy_id' => 42],   // raw meta channel
    'tax_input'   => ['topic' => ['design']],
    'acf'         => ['hero_title' => 'Hello'], // ACF channel
    'key'         => 'event:launch',
])->save();
```

The only additions to the WordPress vocabulary are the two things it has no key for: `acf` (an `update_field()`-shaped map) and Muster's logical `key`/`adopt` identity.

`fill()` is pure sugar over the fluent setters — it dispatches each key to the matching method, so ref resolution, merge-upsert, and the meta-vs-ACF guard all apply unchanged. It merges with setters called before or after it (last write wins), and an unrecognised key throws rather than being silently dropped. Because a Recipe's `define()` returns a builder, the same array shape works there too — so a fixture generated from an existing entity is simply a `fill([...])` array.

</details>

<details>

<summary><strong>Reusable recipes, states, and sequences</strong></summary>

A **Recipe** is a reusable class for one resource shape — a Recipe uses Victuals to produce a resource declaration, not a Model or attribute map (see ADR 0007). It lives in the theme's `muster/Recipes/` directory: implement `define()` with the default shape, and add named variations as methods that return `$this->state(...)`.

{% code title="muster/Recipes/ArticleRecipe.php" %}

```php
final class ArticleRecipe extends \PressGang\Muster\Patterns\Recipe
{
    public function define(int $i): PostBuilder
    {
        return $this->content('post')->slug('article-' . $i);   // populated title/body/ACF
    }

    public function featured(): static
    {
        return $this->state(fn (PostBuilder $b, int $i) => $b->meta(['featured' => true]));
    }
}
```

{% endcode %}

Reuse it in a seed or a test — `count()->create()` seeds a self-keyed batch, states compose immutably, and `Pattern::using()` feeds it to a pattern:

```php
$this->recipe(ArticleRecipe::class)->count(6)->withThumbnail()->create();
$this->recipe(ArticleRecipe::class)->featured()->count(2)->create();

// or drive a Pattern directly, e.g. to attach an after-hook:
$this->pattern('articles')->count(6)
    ->after('welcome-comment', fn ($post, int $i) => $this->comment($post)
        ->key('comment:article:' . $i)->author('Fixture Editor')->content('Welcome'))
    ->using($this->recipe(ArticleRecipe::class));
```

Because a Recipe is a plain class, the *same* shape seeds your dev content and arranges a scenario in a test — the ORM-free equivalent of a Laravel factory used in both a seeder and a test.

</details>

<details>

<summary><strong>Ordered Musters and logical-key references</strong></summary>

Split a large scenario into focused dependencies without creating separate run state:

```php
$this->call(AuthorMuster::class, ArticleMuster::class);
```

Called Musters share the clock, seeded Victuals stream, ownership registry, group selection, and report. Calls run in declared order; recursion and duplicate execution fail with a dependency-path diagnostic. This makes a `SiteMuster` that only calls focused children — like Laravel's `DatabaseSeeder` — the idiomatic way to organise a larger seed: `wp capstan seed` runs the root, and each child is reached exactly once. Because the ownership registry is shared, `acfFor()` support resources are deduped across the whole graph, so children targeting the same field group never collide over the same placeholder.

`ref()` addresses the same stable logical key used for ownership. It can be captured **before** the target exists, because the consuming builder resolves it on `save()`:

```php
$about = $this->ref('page:about');
$menu = $this->menu('Main Menu')->key('menu:main')->postItem($about, 'About');

$this->page()->key('page:about')->title('About')->slug('about')->save();
$menu->save();
```

On a clean first run, save the target before its consumer. To reference a key owned by a called Muster, pass that class explicitly: `$this->ref('user:editor', AuthorMuster::class)`.

</details>

<details>

<summary><strong>Shorthand for registered post types</strong></summary>

Any registered post type is callable directly on the Muster, as shorthand for `post()`:

```php
$this->event()               // same as $this->post('event')
$this->event('Summer show')  // same as $this->post('event')->title('Summer show')
```

Resolution is delegated to WordPress: the method name must be a post type that is **registered at call time**, or the call throws `BadMethodCallException`. A post type whose name collides with a real Muster method (such as `post`) will never reach the shorthand.

</details>

***

## 🔁 Persistence semantics

Every builder created through a Muster requires an explicit `key()`. The concrete Muster class plus that key form stable identity; the native WordPress locator is how Muster **discovers** the current object.

Posts, terms, users, and comments use **merge-upsert** semantics:

{% stepper %}
{% step %}

#### Resolve

Find an owned resource by Muster class and logical key.
{% endstep %}

{% step %}

#### Check

Inspect the current WordPress locator for collisions.
{% endstep %}

{% step %}

#### Create or update

Create when absent. When present, update **only** the fields explicitly supplied.
{% endstep %}

{% step %}

#### Preserve

Omitted fields keep their existing WordPress values. An explicitly empty value clears a field.
{% endstep %}
{% endstepper %}

| Resource           | Current locator                                                 |
| ------------------ | --------------------------------------------------------------- |
| Post, page, or CPT | Post type + slug                                                |
| Term               | Taxonomy + slug                                                 |
| User               | Login                                                           |
| Option             | Option name                                                     |
| Comment or reply   | Post + parent + type + author identity + deterministic GMT date |
| Attachment         | Attachment slug                                                 |
| Menu               | Menu name                                                       |

{% hint style="warning" %}
**A locator match does not prove ownership.** If a matching resource exists but is not registered to this Muster key, saving fails. Call `adopt()` only when the declaration intentionally takes responsibility for that existing object. Adoption never steals a resource already owned by another Muster or key.
{% endhint %}

<details>

<summary><strong>Per-builder behaviour worth knowing</strong></summary>

* **Users** — new users must declare `->password('initial-password')`. Muster sends it only when `wp_insert_user()` creates the user. Later runs leave credentials untouched, because WordPress stores a one-way hash that cannot be compared safely with a plaintext declaration.
* **Comments** — no slug, so the locator uses post, parent, type, author email (or name), and GMT date. Content stays mutable and can be revised without creating a duplicate. Pin `date()` or define a scenario epoch so an otherwise identical declaration has a stable locator. `CommentRef` can be passed to `parent()` to build threaded replies.
* **Options** — use WordPress's own option upsert behaviour.
* **Attachments** — create a file and attachment once, then reuse an existing attachment with the same slug. Changing the declared source does not currently regenerate an existing file.
* **Menus** — the declaration is authoritative: every existing item in the named menu is deleted and the declared items recreated in order.
* **`truncate()`** — permanently deletes every post of the selected type, or every term in the selected taxonomy.

Builders persist by **merge** because a declaration is a partial statement of intent, never a complete resource. A builder that manufactured update defaults would silently erase content Muster did not author.

</details>

***

## 👀 Plan and apply

Every CLI command runs the Muster in a read-only planning context first.

```mermaid
graph LR
    A["✍️ run()"] --> B["📋 Plan — read-only"]
    B --> C{"⚠️ Conflicts?"}
    C -->|Yes| D["🛑 Stop, no writes"]
    C -->|No| E["🔄 Re-resolve state"]
    E --> F["✅ Apply"]
```

| Operation  | Meaning                                                           |
| ---------- | ----------------------------------------------------------------- |
| `create`   | No owned or adopted resource currently exists.                    |
| `update`   | The owned declaration differs, or has authoritative side effects. |
| `keep`     | Comparable declared state already matches WordPress.              |
| `prune`    | An owned or explicitly truncated resource will be deleted.        |
| `conflict` | Ownership or locator safety prevents application.                 |

Without `--dry-run`, Muster prints the plan and then performs a second pass that re-resolves WordPress state and applies the declarations. WordPress has no transaction spanning all of its resource APIs, so the apply pass **validates again** rather than treating the earlier reads as a lock.

{% hint style="warning" %}
A normal CLI run calls `run()` **twice** — once to plan, once to apply. Keep `run()` declarative: no email, no remote APIs, no writes outside Muster builders.
{% endhint %}

Core post, term, user, and option fields can be proven unchanged and reported as `keep`. ACF/meta/taxonomy payloads and authoritative menu rebuilds are conservatively reported as updates until their adapters expose comparable read contracts. Programmatic integrations can inspect `$context->report()->operations()`, `summary()`, or `toArray()`. Each structured operation includes the declaration `group` that produced it, or `null` for an ungrouped run.

***

## 🔐 Ownership, adoption, and cleanup

Muster stores ownership records in the non-autoloaded `pressgang_muster_registry` option. Records hold the concrete Muster class, logical key, resource kind, WordPress ID, subtype, and current locator. WordPress remains the source of truth for the resource itself.

```php
$this->page()
    ->key('page:about')
    ->adopt() // only to claim a pre-existing, unowned page
    ->title('About us')
    ->slug('about-us')
    ->save();

$this->resetOwned();                  // delete everything this Muster owns
$this->pruneOwned();                  // delete owned keys not touched this run
$this->pruneOwned(['page:seasonal']); // ...but also keep this one
```

`pruneOwned()` automatically keeps keys saved in the current run, including reserved `acf:*` support keys. Its optional array means **"also keep"**, not "complete manifest". Both operations leave editor-created and other unowned content alone.

{% hint style="danger" %}
**Never prune after a partial `--only` run.** Declarations in skipped groups cannot be judged stale. Muster enforces this: `resetOwned()` and `pruneOwned()` fail when `--only` is active, because they reconcile the complete ownership scope.
{% endhint %}

***

## 📋 The declarative manifest

For the common whole-surface case — some terms, populated content per type, a page per template, a menu per location — a `SiteMuster` can declare a **manifest** instead of writing each builder. `assemble()` derives all of it through the same primitives (self-keyed patterns, `withThumbnail()`, `content()`), so determinism, ownership, and plan/apply are identical to a hand-written seed:

{% code title="muster/SiteMuster.php" %}

```php
public function run(): void
{
    $this->assemble([
        'terms' => ['topic' => 3],                          // taxonomy => count
        'posts' => [
            'article' => ['count' => 5, 'thumbnail' => true, 'terms' => ['topic' => 'rotate']],
            'post'    => ['count' => 5, 'thumbnail' => true],
            'event'   => ['count' => 5, 'thumbnail' => true, 'terms' => ['topic' => 'rotate']],
        ],
        'pages' => 'templates',    // one page per registered page template
        'menus' => 'locations',    // a menu per registered nav location
    ]);
}
```

{% endcode %}

Each section runs in its own group (`terms:topic`, `posts:article`, `pages`, `menus:primary`), so `--only` selects it. The manifest is the terse default; for anything it can't express — bespoke relationships, conditional content — write a Muster class and `$this->call()` it. The two compose: a manifest for the bulk, a class for the exceptions. See [ADR 0006](https://github.com/pressgang-wp/pressgang-muster/blob/main/docs/adr/0006-seeder-authoring-ergonomics.md).

A post spec can also carry a **`fill`** block of explicit WordPress-native field values — the same array shape a builder's `fill()` takes — applied to every generated row on top of its generated content. It is the manifest's way to declare concrete values, not just generated ones:

{% code title="muster/SiteMuster.php" %}

```php
'posts' => [
    'page' => ['count' => 1, 'fill' => [
        'post_title' => 'About',
        'post_name'  => 'about',
        'acf'        => ['hero_heading' => 'Hello'],
    ]],
],
```

{% endcode %}

Row slugs stay self-keyed unless `fill` sets `post_name`; for different explicit values per row, reach for a Recipe.

## 🌱 Conventional development seed

[Capstan](/ecosystem/capstan) can inspect the active theme and scaffold a starting `SiteMuster` from its registered post types, taxonomies, page templates, menu locations, and ACF JSON:

{% code title="Terminal" %}

```bash
wp capstan make muster          # preview muster/SiteMuster.php
wp capstan make muster --force  # write it once
```

{% endcode %}

{% hint style="info" %}
`make muster` is provided by **Capstan**. The `seed` and `muster` commands below ship with Muster itself.
{% endhint %}

The generated file is owned by the child theme and is never overwritten by the scaffold. It pins a `defaultEpoch()` and creates groups such as `taxonomy:event_type`, `content:event`, `page:contact`, and `menu:main-menu`. Edit its counts, names, content, relationships, group names, and logical keys to fit the project.

{% tabs %}
{% tab title="Conventional seed" %}
{% code title="Terminal" %}

```bash
wp capstan seed --seed=1234     # run the conventional SiteMuster
wp capstan seed --dry-run       # plan without writes
wp capstan seed --fresh         # reset this Muster's owned resources, then run
wp capstan seed --only=content:event
wp capstan seed --format=json   # machine-readable plan and apply reports
wp capstan seed --verbose       # builder and operation identity details
wp capstan seed --quiet         # suppress successful human output
```

{% endcode %}

{% hint style="danger" %}
`wp capstan seed` refuses to run when `WP_ENVIRONMENT_TYPE` is `production`. There is no override flag.
{% endhint %}
{% endtab %}

{% tab title="Named Muster" %}
{% code title="Terminal" %}

```bash
wp capstan muster App\\Muster\\DemoMuster --seed=1234
wp capstan muster App\\Muster\\DemoMuster --dry-run
wp capstan muster App\\Muster\\DemoMuster --only=articles
wp capstan muster App\\Muster\\DemoMuster --format=json
wp capstan muster App\\Muster\\DemoMuster --verbose
```

{% endcode %}

The lower-level runner: you name the class, it runs. It has no production guard and no `--fresh`.
{% endtab %}

{% tab title="Flags" %}

| Flag                 | Effect                                                                             |
| -------------------- | ---------------------------------------------------------------------------------- |
| `--seed=<int>`       | Sets the global seed.                                                              |
| `--epoch=<datetime>` | Pins the fixture clock; overrides `defaultEpoch()`.                                |
| `--dry-run`          | Full read-only plan, stops before application.                                     |
| `--only=<csv>`       | Runs only the named declaration groups.                                            |
| `--fresh`            | (`seed` only) Deletes resources owned by that Muster, then runs.                   |
| `--format=json`      | One object with `status`, ordered `operations`, and `summary`. No human log lines. |
| `--verbose`          | Declared field **names** and full operation identity — never values.               |
| `--quiet`            | Suppresses progress and reports on success; errors remain visible.                 |
| {% endtab %}         |                                                                                    |
| {% endtabs %}        |                                                                                    |

### Groups and `--only`

`--only` filters named declaration **groups**. Put an explicit callback boundary around every independently selectable part of the scenario:

```php
$this->group('articles', function (): void {
    $this->page()->key('page:articles')->title('Articles')->slug('articles')->save();

    $this->pattern('article-fixtures')->count(5)->build(
        fn (int $i) => $this->post()
            ->key('article:' . $i)
            ->title($this->victuals()->headline())
            ->slug('article-' . $i)
    );
});
```

A skipped callback is **never invoked**, so builders, Patterns, Victuals calls, and ACF provisioning inside it perform no reads, writes, or random draws. Names must be non-empty and unique within one pass; groups cannot be nested. Unknown `--only` names fail rather than silently doing nothing.

When `--only` is active, data declarations outside groups also fail. Without `--only`, ungrouped declarations remain valid. Combining `--fresh` with `--only` intentionally clears everything the Muster owns, then rebuilds only the selected groups.

{% hint style="info" %}
`--fresh` is ownership-aware and needs no custom `fresh()` method. The broad `truncate()` builder remains available for deliberately disposable databases, but the conventional fresh seed does not use it.
{% endhint %}

***

## 🎲 Determinism

Randomness and time are **separate inputs**.

{% tabs %}
{% tab title="🎰 Seed — the words" %}
An explicit seed gives Faker-backed values a repeatable sequence:

{% code title="Terminal" %}

```bash
wp capstan seed --seed=1978
```

{% endcode %}

A Pattern seed overrides the run seed for that Pattern. Calls within a Pattern share one scoped `Victuals` instance, so the sequence is stable when the seed, call order, locale, and inputs are stable.
{% endtab %}

{% tab title="🕰️ Epoch — the dates" %}
The fixture epoch defines what relative dates mean:

```php
public static function defaultEpoch(): string
{
    return '2026-01-01 09:00:00+00:00';
}
```

Use `$this->epoch()` for the reference instant and `$this->at('+1 week')` for a resolved date. Victuals `date()`, `datetime()`, and `dateBetween()` use that same clock. Override temporarily:

{% code title="Terminal" %}

```bash
wp capstan seed --epoch="2027-04-05 09:00:00+00:00"
```

{% endcode %}

An explicit CLI epoch takes precedence over `defaultEpoch()`.
{% endtab %}
{% endtabs %}

Without either input, Muster captures the system clock **once** and shares it across plan and apply. That keeps one invocation coherent, but does not make separate invocations repeatable.

{% hint style="warning" %}
**Two limits.** Faker's seeding uses PHP's global `mt_rand` stream, so interleaving independently seeded Faker instances can change their sequences. And `victuals()->raw()` bypasses Muster's curated clock behaviour.
{% endhint %}

<details>

<summary><strong>WordPress-shaped content helpers</strong></summary>

* `imageUrl($width, $height, $label)` — a seeded, self-contained SVG data URL with no external placeholder service. For a real Media Library object, use `AttachmentBuilder::placeholder()` instead.
* `gutenbergBlocks($paragraphs)` — serialized core heading and paragraph blocks.
* `richContent($sections)` — escaped semantic HTML with headings, lists, links, and a blockquote.
* `repeaterRows($count, $schema)` — ACF-shaped row arrays. Callable schema values receive the Victuals instance and the one-based row index; constants are copied into each row.

</details>

[Shakedown](/ecosystem/shakedown) supplies a fixed seed and pins published dates inside its disposable sandbox, which keeps its generated visual fixtures stable.

***

## 🧬 ACF-derived coverage fixtures

`acf-json` is the machine-readable description of a theme's editorial surface. Muster reads it to generate values, instead of duplicating every field definition in seed code:

```php
$this->post('event')
    ->key('event:example')
    ->title('Example event')
    ->slug('example-event')
    ->acf($this->acfFor('event'))
    ->save();
```

| Variant               | Fills                                                       |
| --------------------- | ----------------------------------------------------------- |
| `populated` (default) | Every generatable field.                                    |
| `minimal`             | Required fields only, including required nested sub-fields. |

The generator handles common scalar fields plus groups, repeaters, flexible content, galleries, and relational fields.

The target is any location value a field group declares: a post type (`event`), a page **or** post template path (`page-templates/contact.php`), an options-page slug (`site-options`), a `page_type` such as `front_page`, or a nav-menu-item location (`location/primary`). Groups on any of these resolve — not just post types and page templates.

{% hint style="warning" %}
**`acfFor()` is provisioning, not a side-effect-free lookup.** Relational and media fields need real WordPress IDs, so it may create supporting attachments, posts, or terms. Those receive reserved `acf:*` logical keys and are owned by the **run's root Muster** — so several chained seeders that target the same field group share one placeholder rather than contending to own it. Relationship stub posts are dated a year before the fixture epoch and carry a placeholder featured image, so they never head date-ordered feeds or render thumbnail-less — and, because the date derives from the shared clock, output stays deterministic.
{% endhint %}

When ACF is active, the CLI wires `LiveAcfAdapter` and writes through `update_field()`. When ACF is unavailable, ACF payloads are not persisted.

{% hint style="warning" %}
**Meta and ACF are separate channels — don't cross them.** `->acf([...])` writes through ACF's `update_field()`, which also stores the field-key reference `get_field()` needs; `->meta([...])` writes raw post/term meta through `update_post_meta()`. They are not interchangeable — writing an ACF field's name as raw meta reads back empty. Muster guards this: a `meta()` key that `acf-json` registers as an ACF field for the post type or taxonomy is rejected on `save()` (plan and apply alike), pointing you at `acf()` instead.
{% endhint %}

This is especially valuable to [Shakedown](/ecosystem/shakedown): every ACF field group gets populated **and** minimal fixtures in an isolated sandbox, exercising both rich content and the sparse-but-valid editorial states where empty-link and missing-image bugs hide.

***

## 🧰 Builder reference

<details>

<summary><strong>Entry points</strong></summary>

| Entry point                                                               | Creates or updates                                         |
| ------------------------------------------------------------------------- | ---------------------------------------------------------- |
| `$this->group('articles', fn () => ...)`                                  | A named declaration boundary selectable by `--only`        |
| `$this->post('event')->key('event:1')`                                    | Posts and custom post types                                |
| `$this->page()->key('page:about')`                                        | Pages                                                      |
| `$this->term('category')->key('category:news')`                           | Taxonomy terms                                             |
| `$this->user('editor')->key('user:editor')->password('initial-password')` | Users; passwords are create-only                           |
| `$this->option('name')->key('option:name')`                               | Options                                                    |
| `$this->comment($post)->key('comment:welcome')`                           | Comments and threaded replies                              |
| `$this->attachment('hero')->key('attachment:hero')`                       | Media attachments and deterministic placeholders           |
| `$this->menu('Main Menu')->key('menu:main')`                              | Navigation menus, items, nesting, and locations            |
| `$this->resetOwned()`                                                     | Every resource owned by this concrete Muster               |
| `$this->pruneOwned([...])`                                                | Stale owned resources not touched or additionally retained |
| `$this->truncate()`                                                       | Immediate destructive post-type or taxonomy reset          |

</details>

Refs returned by `save()` carry real WordPress IDs without exposing database-table details. Pass them to post and comment parents, menu items, attachment relationships, and featured-image assignments. `ref('logical:key')` gives a save-time handle backed by the same ownership registry.

***

## 🧪 Real WordPress verification

Muster keeps a fast PHPUnit 11 suite for focused feedback, plus a separate WordPress 7 / PHPUnit 9 integration harness for behaviour that stubs cannot prove — posts, terms, users, options, comments, ownership, dry-run planning, merge updates, and pruning against real core APIs and a real MySQL database.

{% code title="Terminal" %}

```bash
export WP_TEST_DB_NAME=muster_test
export WP_TEST_DB_USER=root
export WP_TEST_DB_PASSWORD=secret
export WP_TEST_DB_HOST=127.0.0.1
bin/run-integration-tests.sh
```

{% endcode %}

{% hint style="danger" %}
**The database must be disposable.** WordPress's test harness installs and clears its prefixed tables. Never point it at a real site.
{% endhint %}

GitHub Actions runs the unit suite on PHP 8.3 and 8.4, plus the integration suite against WordPress 7.0.1.

Test cases can `use AssertsWordPressFixtures` for focused post, term, user, option, and comment assertions. `MusterSnapshot::serialize()` and `assertMatches()` produce versioned structured-report JSON for regression checks. Volatile WordPress IDs are omitted by default; include them only when the database lifecycle makes them stable. Snapshot creation or replacement requires an explicit `write()`.

***

## 🛳️ With the fleet

* [**Capstan**](/ecosystem/capstan) scaffolds the theme's `SiteMuster` via `wp capstan make muster`.
* [**Shakedown**](/ecosystem/shakedown) uses Muster for ACF-derived state fixtures in a disposable WordPress sandbox.
* [**Bosun**](/ecosystem/bosun) can brief coding agents on the installed PressGang tools and project conventions.


# Shakedown

End-to-end browser testing for PressGang themes with zero tests to write — the route matrix, fixtures, and checks are all derived from your theme.

A shakedown cruise is the sea trial of a new vessel: take her out, push every system, find what rattles before the passengers board. Shakedown does the same for your theme — and because PressGang themes declare their post types, taxonomies, templates and menus in `config/`, it can **derive the whole test suite from the site itself**. You write nothing to get started.

{% hint style="success" %}
**Start in the project you want to test.** Install Shakedown there, run `npx shakedown init`, then choose an attached, sandbox or regression run. Shakedown derives representative routes from your site; you do not maintain a separate generic URL list.
{% endhint %}

## 🧰 Commands at a glance

Shakedown runs in one of three **modes** — keep the distinction in mind, everything below builds on it:

| Mode                                                     | Answers                                          | Touches your database?                  |
| -------------------------------------------------------- | ------------------------------------------------ | --------------------------------------- |
| **Attached** — your live local site                      | "Is my site healthy *right now*?"                | Never writes — read-only GETs           |
| **Regression** — derived production/candidate comparison | "What changed, and is the candidate healthy?"    | Anonymous GETs; browser writes blocked  |
| **Sandbox** — a disposable throwaway WordPress           | "Is my *theme* correct, independent of content?" | N/A — its own database, vaporised after |

| Command                                                                      | Mode                       | What it does                                                            |
| ---------------------------------------------------------------------------- | -------------------------- | ----------------------------------------------------------------------- |
| `npx shakedown init`                                                         | Setup                      | Creates project configuration and ignores generated reports             |
| `npx shakedown`                                                              | Attached                   | Runs every pass against your local site                                 |
| `npx shakedown regression --against=production --candidate=staging`          | Regression                 | Compares derived paths and captures evidence at selected viewport sizes |
| `npx shakedown regression --level=full --coverage=exhaustive`                | Regression                 | Adds eligible public content and terms to the route plan                |
| `npx shakedown regression --level=full --viewports=desktop --routes=/about/` | Regression                 | Focuses on one discovered route at desktop size                         |
| `npx shakedown matrix`                                                       | Attached                   | Prints the route matrix without running checks                          |
| `npx shakedown sandbox`                                                      | Sandbox                    | Spins up the throwaway WordPress, seeds fixtures, runs every pass       |
| `npx shakedown sandbox --update-snapshots`                                   | Sandbox                    | Re-mints visual regression baselines                                    |
| `npx shakedown ui`                                                           | Attached                   | Playwright's UI / watch mode for the ordinary passes                    |
| `npx playwright show-report`                                                 | Attached / sandbox reports | Opens a Playwright HTML report, not a Regression Report                 |

## 📦 Installation and project setup

You need Node 20+, [WP-CLI](https://wp-cli.org/), and a local WordPress installation. Choose the directory that will own your tests and reports. For a complete site repository such as PenARC, use its project root. For a repository containing only a theme, use the theme root. Keep using that same directory for subsequent commands.

```sh
cd ~/Projects/your-site
```

If it has no `package.json`, create a private one first:

```sh
npm init -y
npm pkg set private=true --json
```

Install a Shakedown release containing the commands you want to use:

```sh
npm install --save-dev @pressgang-wp/shakedown
npx playwright install chromium
```

Shakedown is a development dependency. `npx shakedown` invokes the installed CLI; it does not mean you should run tests inside the Shakedown source repository. Commit the package manifest and lockfile with your project.

If your npm release does not yet contain `init` or `regression`, use a development checkout containing those commands. See [installing from a local checkout](/ecosystem/shakedown/shakedown-regression#using-a-development-checkout).

## 🛠️ Initialise with `shakedown init`

From the project directory, run:

```sh
npx shakedown init
```

The command detects WordPress in the current directory, an ancestor, or common locations such as `wp`, `wordpress`, `web/wp`, `public/wp` and `public`. It asks you to confirm the WordPress directory and local URL, reading the URL through WP-CLI when possible. It then asks for a production URL and optional staging URL. Enter production to configure regression testing; leave it blank for attached-only setup. The WordPress directory must contain `wp-load.php`.

`init` creates two project-level settings:

* **`shakedown.config.json`** — the WordPress location, local URL and any regression environments you supplied.
* **`.gitignore`** — appends `/.shakedown/` so generated reports are not committed. Existing ignore rules are preserved.

Commit these changes. `init` does not install packages, start tests, migrate your database or create visual baselines. It will not overwrite a config or create a second one below an existing parent config. If configuration already exists, edit it directly and skip this step.

For setup without prompts:

```sh
npx shakedown init --site-path=./wp --base-url=https://your-site.test \
  --reference=https://example.org --staging=https://staging.example.org --yes
```

`--yes` accepts detected values without asking questions. Non-interactive runs never wait for input; supply missing values with flags. Use `npx shakedown init --help` to see all options. If WP-CLI cannot read the local URL, provide `--base-url`; ambiguous WordPress locations require a choice or `--site-path`.

## ⚙️ How configuration works

Shakedown looks for `shakedown.config.json` in the current directory, then its ancestors. A generated regression config looks like this:

```json
{
  "sitePath": "wp",
  "baseUrl": "https://your-site.test",
  "regression": {
    "references": { "production": "https://example.org" },
    "candidates": {
      "local": "https://your-site.test",
      "staging": "https://staging.example.org"
    }
  }
}
```

| Setting                 | Meaning                                                                             |
| ----------------------- | ----------------------------------------------------------------------------------- |
| `sitePath`              | Directory WP-CLI inspects. Relative paths resolve from the config file's directory. |
| `baseUrl`               | Public URL of that local installation; also the source origin for derived routes.   |
| `regression.references` | Named comparison references, usually production.                                    |
| `regression.candidates` | Named updated sites, usually local and optionally staging.                          |

The config describes environments, not a hand-maintained test plan. Routes come from the theme and WordPress state. Regression URLs are plain HTTP(S) origins, without credentials or path prefixes. Selecting staging does not remove the need for local WordPress discovery.

The **directory where you invoke Shakedown** is the workspace: reports, matrices, `tests/e2e/` journeys and `tests/__screenshots__/` baselines are resolved there. Finding a config in an ancestor does not move that workspace. Run consistently from your chosen project root to keep artifacts together.

Attached testing can still work without a config when Shakedown finds `wp-config.php` in the current directory or an ancestor and WP-CLI can read the home URL. Regression needs named references and candidates. Additional settings for fixtures, search and suppressions are covered [below](#additional-configuration).

## Regression tests

Once the local site is running, its migrations are applied and uploads are available, compare it with production:

```sh
npx shakedown regression --against=production --candidate=local
```

Use `--candidate=staging` for a configured, anonymously reachable staging site. Password-protected staging is not currently supported. Both sites receive anonymous GET-only observations; no forms are submitted or baselines updated.

Shakedown derives one route plan and captures matching paths at the selected viewport sizes. It reports candidate health separately from reference differences: a production defect does not make the candidate correct, and an intentional content change is not automatically a regression.

At the end, open the printed `.shakedown/regression/run-…/index.html` path in your browser. Check coverage, review **Repeated changes**, then use **Route index** to open **Candidate correctness**, **Behaviour changes** and **Presentation and content changes**. Plain-language summaries precede Technical evidence. Full runs include paired screenshots and links to Playwright visual comparisons for matched pages. Repeated image advisories share one representative; element panels omit unchanged values and keep position-only image movement in layout evidence. Full arrays and omitted-route inventories remain available in `run.json`. This report is separate from `npx playwright show-report` and the ordinary Trial Report. Each rerun creates a new folder; share the whole folder so screenshots remain available.

Levels (`errors`, `core`, `full`), coverage (`sampled`, `exhaustive`) and viewports are independent choices. Defaults are full checks, sampled routes and all three viewport presets unless your config selects otherwise. Use `--routes` for focused investigation of discovered paths. A full run does not imply exhaustive coverage or exercise JavaScript controls.

The report provides acceptance snippets for intentional comparison differences and a downloadable `summary.md` for handovers. Acceptance policies are explicit and disclosed; they do not clear candidate health failures. Structural capture limitations and unavailable screenshot diffs remain visible for review.

See [Regression testing](/ecosystem/shakedown/shakedown-regression) for the complete walkthrough, result categories, exit codes and accepting intentional differences.

## ⚡ Attached tests

{% code title="Terminal" %}

```bash
npx shakedown
```

{% endcode %}

This uses your configured local site, or automatic discovery when no config is needed. The ordinary passes include visual baseline checks, so missing baselines can fail even when the page renders correctly. Want to inspect the derived routes first?

{% code title="Terminal" %}

```bash
npx shakedown matrix
```

{% endcode %}

```
⚓ 54 routes for https://mysite.test (via capstan)
  [200] home            https://mysite.test/
  [200] archive:event   https://mysite.test/events/
  [200] single:event    https://mysite.test/events/spring-fair/
  [200] term:category   https://mysite.test/news/category/research/
  ...
```

The matrix covers your front page, every post type's archive plus sample singles, taxonomy term pages, every page using a registered page template, internal menu targets, a search probe, and a 404 probe. Add a post type to `config/custom-post-types.php` and the next run covers it automatically. 🗺️

It also covers the surfaces that are easy to forget because nothing links to them prominently:

| Family              | Why it's there                                                                                                      |
| ------------------- | ------------------------------------------------------------------------------------------------------------------- |
| **Author** archives | `author.php` is a template most themes ship and few ever open                                                       |
| **Date** archives   | likewise `date.php` — year and month, taken from your newest post                                                   |
| **Pagination**      | page 2 of any archive with more posts than fit; where off-by-one and empty-page bugs live                           |
| **Feeds**           | the main feed and per-post-type feeds — a feed that fatals is still a broken site                                   |
| **Empty search**    | a term that matches *nothing*, so the no-results branch gets exercised — your `searchTerm` is chosen to find things |

Feeds are checked by pass 00 only: a full-page screenshot or an axe audit of XML measures nothing. Page 2 appears only when a post type genuinely has more published posts than `posts_per_page`.

## 🧪 What gets checked

| Pass                   | Checks                                                                                                                                |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| **00 · Availability**  | Right HTTP status · no PHP/Twig error output · a `<title>` present. HTTP-only, so it sweeps the whole site in seconds.                |
| **01 · Integrity**     | Real Chromium render: no JS exceptions, console errors, failed requests, or broken images.                                            |
| **02 · Accessibility** | axe-core against WCAG 2.1 A/AA. Serious/critical violations fail; minor ones report as advisory.                                      |
| **03 · Visual**        | Full-page screenshots against baselines committed in your theme. Missing baselines fail; only an explicit sandbox update writes them. |

When something fails you get the exact URL, what was expected, and a Playwright trace to replay step-by-step. `npx shakedown ui` gives you watch mode while you fix it; `npx playwright show-report` browses the last run.

{% hint style="info" %}
Real story: on its very first outing, the two commands above found a site-breaking Twig fatal across an entire section of an unlaunched site — within ninety seconds of `npm install`. That's the pitch.
{% endhint %}

## 🏝️ The sandbox

Attached mode tests your site *as it is* — real content, full plugin stack, strictly **read-only**. The sandbox answers a different question: *is my theme correct?*

{% code title="Terminal" %}

```bash
npx shakedown sandbox
```

{% endcode %}

This assembles a **throwaway WordPress** in a temp directory: your code symlinked read-only, its own fresh SQLite database, its own uploads — think Laravel's in-memory test database, for WordPress. Its install defaults (Hello world!, Sample Page, the seeded comment) are cleared first, so only seeded fixtures exist and no version-dependent default content leaks into feeds, archives, or menus. It then seeds in three layers, convention-first:

1. **Your theme's own fixtures.** If the theme ships [Muster](/ecosystem/muster) seeders (a top-level `muster/` directory), the sandbox runs them via `wp capstan seed` — so your real menus, terms, pages and relationships are present, exactly as on a dev site. A theme that ships none is unaffected; the derived layer below still covers it.
2. **Derived ACF state fixtures.** On top, for every field group, one page/post with *every field populated* and one with *only required fields* — the sparsest content an editor can legally publish, which is exactly where empty-link and missing-image bugs live.
3. **Per-journey scenarios.** Finally, any `tests/e2e/*.setup.php` in your theme runs, so an authored journey can arrange the precise, deterministic scenario its paired `*.spec.mjs` asserts on.

Then it runs all passes and vaporises.

```
⚓ sandbox up at http://127.0.0.1:54223 (isolation verified)
⚓ seeded theme baseline via `wp capstan seed`
⚓ seeded 34 ACF state fixtures via Muster
⚓ capstan doctor: 11 checks, 0 failures, 0 warnings
⚓ 22 routes (via capstan)
127 passed (18s)
```

{% hint style="warning" %}
**Your database is never touched.** Attached mode only ever GETs pages. Seeding happens exclusively in the sandbox, and every boot runs an isolation check that *proves* the served WordPress lives in the temp directory before any test traffic flows — if it can't prove it, it refuses to run.
{% endhint %}

Plugins are **allowlisted** in the sandbox (default: none — ACF loads via mu-plugins). The sandbox tests your theme, not your plugin stack; a run that passes in the sandbox but fails attached tells you a plugin is the culprit.

## 📸 Visual baselines

Once your passes are green, mint the screenshots:

{% code title="Terminal" %}

```bash
npx shakedown sandbox --update-snapshots
```

{% endcode %}

Baselines land in your theme at `tests/__screenshots__/` (per-platform) — **commit them**. Because fixtures are seeded deterministically and dates are pinned, snapshots are byte-stable across runs: a future diff means the *theme* changed, not the content.

{% hint style="info" %}
**Baselines are only ever written on purpose.** A route with no baseline *fails* rather than quietly acquiring one, and `--update-snapshots` is refused outside sandbox mode. A baseline captured from your live site records whatever was published that day — and because singles are sampled newest-first, the very pages it captured drift as you edit. Deterministic fixtures are what make a snapshot mean something, so baselines come from the sandbox or not at all.
{% endhint %}

Everything a baseline rests on is pinned to an exact version: the WordPress core it was rendered against, the Muster revision that generated the fixtures, and the SQLite drop-in underneath (verified by checksum on download). Otherwise an upstream release could rebreak every baseline you own on its release day, with no change on your side.

## 📋 The Trial Report

Attached and sandbox runs write `.shakedown/trial-report.html` — a self-contained, client-readable page: summary numbers, a screenshot preview per route, a route × pass matrix, and failures in plain English (no stack traces). Attach it to a PR, or send it with a handover. The developer-grade report with traces lives separately in `playwright-report/`, and `run.json` beside it carries the same run for anything that wants to consume it.

It reports what happened rather than the tidiest version of it. A route that failed and then passed on a retry is marked **flaky** with its first failure shown, not folded into the passes — on a shared server a retry absorbs a load transient, but the same signature can mean a race in your theme, and that's your call to make rather than the report's. Suppressed categories are listed. A run that checked nothing says so, instead of leaving the previous run's report sitting there looking current.

## Save regression preferences in your theme

Keep settings in the theme's `shakedown.config.json`. Merge these entries into its existing `regression` object, preserving the reference and candidate URLs:

```json
"defaultLevel": "full",
"coverage": "exhaustive",
"defaultViewports": ["desktop", "tablet", "mobile"],
"accessibility": "off"
```

Then run `npx shakedown regression` from that directory. CLI flags override saved settings for one run. `--routes` is currently command-line only. Accessibility is on by default for core/full; the example opts out explicitly while retaining visual comparisons. To opt out just once, use `--accessibility=off`.

Axe accessibility findings describe candidate health, not newly introduced regressions. The report discloses skipped audits. See the walkthrough for [accessibility and deployment review](/ecosystem/shakedown/shakedown-regression#accessibility-and-deployment-review) and [all regression settings](/ecosystem/shakedown/shakedown-regression#other-settings-and-limits).

## Additional configuration

Merge optional settings into the config created by `init`; keep its site and regression settings. For example, these settings control search and sandbox fixtures:

{% code title="shakedown.config.json" %}

```json
{
  "searchTerm": "research",
  "sandbox": {
    "plugins": ["contact-form-7"],
    "map": { "assets": "patterns/public/assets" },
    "seed": 42,
    "epoch": "2026-01-01T09:00:00+00:00"
  }
}
```

{% endcode %}

* `searchTerm` — a word that actually appears in your content, for the search probe.
* `sandbox.plugins` — plugins the sandbox should activate (forms plugins, mostly).
* `sandbox.map` — URL paths your web server serves via rewrites (e.g. a pattern library's assets), so the sandbox can mirror them.
* `sandbox.seed` — integer controlling Muster's deterministic generated-value sequence.
* `sandbox.epoch` — timezone-qualified ISO 8601 fixture datetime used by post dates and every relative Victuals/ACF date helper. Randomness and time are separate inputs.

Your own journey tests (form submissions, checkout flows) live in the workspace's `tests/e2e/` — when present, they run alongside the ordinary attached/sandbox passes. Regression mode does not run those journeys.

### 🔇 Suppressing what isn't yours

The passes are strict on purpose: zero console errors, zero PHP notices, no serious axe violations. On a real site some of that noise belongs to somebody else — a tag manager logging to the console, a deprecation raised inside ACF on a newer PHP, a contrast rule your palette loses deliberately. An `ignore` block records what's already been judged, so you never have to choose between the noise and switching a whole pass off:

{% code title="shakedown.config.json" %}

```json
{
  "ignore": {
    "routes":        ["/private-area", "/wp-json/"],
    "consoleErrors": ["googletagmanager", "ERR_BLOCKED_BY_CLIENT"],
    "requests":      ["/wp-json/"],
    "phpIssues":     ["wp-content/plugins/advanced-custom-fields-pro/"],
    "errorSignatures": ["on https://mysite.test/alerts/"],
    "a11yRules":     ["color-contrast"]
  }
}
```

{% endcode %}

Patterns are plain **substrings**, case-sensitive — not regex, not globs. Paste the text out of a failure message and it's the pattern that silences it. `a11yRules` is the exception: exact axe rule IDs, handed to axe's own `disableRules()`.

`phpIssues` matches the whole signature — `<message> in <path>:<line>`, the path relative to WordPress — so a pattern can name an **origin** (`wp-content/plugins/…/`) as easily as a message, and stays portable across machines and CI.

`errorSignatures` covers the awkward case where a page's *content* legitimately reads "Warning: " — a health site's article about risk, say. It matches `<signature> on <url>`, so naming the URL suppresses the body scan for that one page rather than disabling it everywhere.

{% hint style="warning" %}
**Nothing is suppressed quietly.** Every active pattern is printed when the matrix is derived, ignored routes are counted, and the Trial Report ends with a *Suppressed by configuration* table — so a clean run can be read for what it is. A mistyped key (`consoleError`, singular) is an error rather than a silent no-op that suppresses nothing while looking like it works.
{% endhint %}

## 🤖 CI

One caller workflow gives every push the full sandbox suite — no MySQL, no Docker, no database dump. WordPress core is downloaded bare and your theme's own `composer.json` provisions the parent and plugins:

{% code title=".github/workflows/shakedown.yml" %}

```yaml
name: Shakedown
on: [push, pull_request]
jobs:
  shakedown:
    uses: pressgang-wp/pressgang-shakedown/.github/workflows/shakedown.yml@main
    secrets:
      COMPOSER_AUTH: ${{ secrets.COMPOSER_AUTH }}   # ACF Pro credentials
```

{% endcode %}

The Trial Report and route matrix upload as artifacts on every run. Suits theme-shaped repos (the repo *is* the theme).

Versions are **pinned, not floating**: `wp-version` and `muster-ref` both default to exact revisions verified against that Shakedown release, so neither core markup nor fixture behaviour can drift when an upstream `main` moves. Pass `latest` explicitly when you want to test forward compatibility on purpose.

## 🛞 Better with the fleet

Shakedown works on any PressGang site out of the box, and gets sharper with its shipmates installed:

* [**Capstan**](/ecosystem/capstan) — the matrix gains an *oracle*: each route annotated with the template and controller that *should* render it, asserted at runtime. Silent fallbacks to `index.php` become hard failures. `wp capstan doctor` also runs as a pre-flight, aborting before any browser launches if the theme's config is broken.
* [**Muster**](/ecosystem/muster) — runs the theme's own seeders as the sandbox baseline (via `wp capstan seed`) and powers the derived ACF state fixtures on top. Without it, the sandbox still runs; it just skips seeding.

Route, controller, context, and config-dump validation belong to this Capstan/Shakedown boundary. Do not duplicate them in PHPStan conventions or in new Capstan commands: PHPStan handles static PHP signals, while Shakedown uses Capstan's runtime oracle to prove what the theme actually renders.

The sandbox also counts **PHP notices, warnings and deprecations on every request** — even when display and logging are off — and fails any route that raises one. A page can look perfect and still be noisy underneath. The failure quotes the signature verbatim, so if the notice comes from a dependency rather than your theme you can paste it straight into `ignore.phpIssues`.

## 🧯 Troubleshooting

* **"No WordPress found"** — run `npx shakedown init` from the project root and supply `--site-path` if discovery is ambiguous. For an existing config, check that `sitePath` resolves to the intended installation.
* **"Configuration already exists"** — setup is already present locally or in a parent directory. Edit that config; `init` deliberately does not overwrite or shadow it.
* **Unknown `init` or `regression` command** — your installed Shakedown version lacks the feature. Use a version or [development checkout](/ecosystem/shakedown/shakedown-regression#using-a-development-checkout) containing it.
* **Cannot find the regression report** — open the `index.html` path printed by the regression command. `npx playwright show-report` opens the separate Playwright report.
* **Pattern-library themes** — if your Twig partials live outside the theme (e.g. `patterns/templates/`), register that path with Timber via a `timber/locations` snippet, and map its assets with `sandbox.map`.
* **Sandbox asset 404s** — that's `sandbox.map` territory: tell it what your server rewrites.
* **WooCommerce / WPML / multisite** — attached mode only for now; their schemas need the MySQL lane (on the roadmap).
* **State fixtures skipped** — Muster wasn't found; install it in the theme via Composer, or point `sandbox.musterPath` at a checkout.

{% hint style="info" %}
Shakedown is in active development (beta). Commands and config are stable in shape but may still grow — pin a tag once releases are cut, and expect the odd sharp edge to have a friendly error message.
{% endhint %}


# Regression testing

Install Shakedown in your project, compare an updated site with production, and review evidence at selected viewport sizes.

Use regression mode when you have updated a theme and want to compare the result with the deployed site. Shakedown discovers representative routes from local WordPress and the theme's declarations, then visits the same paths on both sites. You do not maintain a separate generic URL list or write tests for each page.

The workflow is **install → initialise → compare → review → fix and rerun**. Run these commands in the project you are testing, such as PenARC. That project owns the configuration and reports, even when Shakedown is installed from a separate development checkout.

## 1. Install in your project

You need Node 20+, WP-CLI, and a local WordPress installation that WP-CLI can inspect. Start in the directory where you want configuration and reports to live:

```sh
cd ~/Projects/your-site
```

If that directory has no `package.json`, create a private project manifest first:

```sh
npm init -y
npm pkg set private=true --json
```

For an npm release containing `init` and `regression`, install the package and its browser:

```sh
npm install --save-dev @pressgang-wp/shakedown
npx playwright install chromium
```

### Using a development checkout

If your installed release does not contain these commands, use a Shakedown checkout that does. With the two repositories beside each other, prepare its dependencies and browser once:

```sh
cd ../pressgang-shakedown
npm ci
npx playwright install chromium
cd ../your-site
npm install --save-dev ../pressgang-shakedown
```

The dependency will be `file:../pressgang-shakedown`. It uses that checkout's code and requires that relative directory to remain available. For teammates and CI, use a released version containing the feature or provide the same checkout layout. The browser installation above runs in Shakedown's checkout because npm may not expose dependency binaries through a local package link.

Verify the CLI from your site project:

```sh
npx shakedown init --help
```

If this reports an unknown command, check which Shakedown version or checkout you installed before continuing.

## 2. Initialise the project

From the same project directory, run:

```sh
npx shakedown init
```

The prompts ask for:

| Prompt              | What to enter                                                                                      |
| ------------------- | -------------------------------------------------------------------------------------------------- |
| WordPress directory | The directory containing `wp-load.php`, often `wp`. Press Enter to accept a detected path.         |
| Local public URL    | Your site's browser URL, for example `https://your-site.test`. The default is read through WP-CLI. |
| Production URL      | The reference site, for example `https://example.org`. Enter this to enable regression setup.      |
| Staging URL         | An optional second candidate. Leave it blank if you only need local testing.                       |

If WordPress cannot be detected, enter its path. If multiple installations are found, choose the intended one. If WP-CLI cannot read the home URL, enter it explicitly. Environment URLs must be HTTP(S) origins without credentials, path prefixes, queries or fragments.

Setup creates `shakedown.config.json` and adds `/.shakedown/` to `.gitignore`. Commit those files and the package manifest/lockfile with your project. Generated reports remain untracked. Setup does not install dependencies, start tests, run migrations or change WordPress data.

A typical generated config is:

```json
{
  "sitePath": "wp",
  "baseUrl": "https://your-site.test",
  "regression": {
    "defaultViewports": ["desktop", "tablet", "mobile"],
    "references": {
      "production": "https://example.org"
    },
    "candidates": {
      "local": "https://your-site.test",
      "staging": "https://staging.example.org"
    }
  }
}
```

`sitePath` is relative to the config file. It identifies the local installation used for **discovery**. `baseUrl` is that installation's public origin. `references` and `candidates` name the environments to compare. The production and selected candidate origins must differ.

{% hint style="info" %}
Already have a config? Skip `init` and inspect or edit it. The command refuses to overwrite an existing config or shadow one in a parent directory. Leaving the production prompt blank creates attached-only configuration; add the `regression` section later if you want comparisons.
{% endhint %}

For scripted setup, supply the values as flags:

```sh
npx shakedown init --site-path=./wp --base-url=https://your-site.test \
  --reference=https://example.org --staging=https://staging.example.org --yes
```

`--yes` skips interactive questions and uses detected values where possible. Non-interactive runs never wait for input; missing required values produce an error. This is a single-project setup command. Existing central configurations with a `targets` map are edited directly and selected with `--target=<name>`.

## 3. Prepare the site and start the comparison

Make sure local WordPress and its database are running. Apply your project's migrations and make its uploads available before testing. Similar content on both sides makes differences easier to interpret, but Shakedown does not import a database or require production content to be identical.

Compare production with local:

```sh
npx shakedown regression --against=production --candidate=local
```

Or select your configured staging candidate:

```sh
npx shakedown regression --against=production --candidate=staging
```

Local WordPress is still needed for discovery when the candidate is staging. Keep running from the project directory: it determines where reports are written.

The run derives its plan through Capstan (or the bundled fallback), adds supplementary routes such as feeds and page 2, and can add a bounded sample of production navigation links. It checks candidate health and captures paired screenshots at the selected viewport sizes in full runs. Feeds receive HTTP checks only. This is sampled coverage by default. Use `--coverage=exhaustive` to add the discovered public content inventory, or `--routes` to investigate particular discovered paths.

Both sites are observed anonymously using GET requests. No forms are submitted, no observer is installed on production, and no sandbox baselines are created or updated. Password-protected or login-only staging is not supported by this mode; use a reachable local candidate. Blocked traffic and access failures are disclosed.

Let the command finish. Larger sites can take several minutes. A failure exit code does not mean the report is missing: read the final output for its location.

## 4. Open the findings

The terminal prints a path like:

```
Regression report: /path/to/your-site/.shakedown/regression/run-AbCd12/index.html
```

Open **that run's `index.html`** in your browser. On macOS, using the actual path printed by your run:

```sh
open .shakedown/regression/run-AbCd12/index.html
```

You can also open the file through Finder or your editor. `.shakedown` is a hidden directory; on macOS, Command–Shift–Period toggles hidden files in Finder.

{% hint style="info" %}
The Regression Report is separate from the ordinary Trial Report and Playwright HTML report. `npx playwright show-report` does not open it. There is no dedicated Shakedown report-opening command yet; open the printed HTML file directly.
{% endhint %}

Work through the report in this order:

1. **Check the run state and summary.** “Complete” means execution finished, not that the candidate passed. Counts are route/viewport observations: a page checked on desktop and mobile contributes two observations. The health count is observations with findings, not the number of unique underlying bugs.
2. **Check coverage and Repeated changes.** Confirm that the route and viewport you care about were visited. Review repeated changes together, then use Route index to open individual page evidence.
3. **Check the route status and Candidate correctness when present.** Detailed HTTP/rendering, JavaScript, asset and enabled accessibility failures appear here. Routes without recorded health failures use a compact heading status.
4. **Read Behaviour changes and Presentation and content changes.** Start with the plain-language summaries. Expand Technical evidence for the concise delta; full arrays remain in `run.json`.
5. **Compare the screenshots.** Reference is production; candidate is the updated site. Click a thumbnail to view the full-size capture, or open the highlighted Playwright comparison under Appearance changed when available.
6. **Check transport restrictions and capture limits.** Blocked third-party requests, pending lazy images or a reached scrolling limit can affect evidence.

The report separates these outcomes:

| Finding                   | How to interpret it                                                                                                 |
| ------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| Candidate health failure  | A correctness check failed. Production may have the same problem.                                                   |
| Difference from reference | Something changed; decide whether it is intended, content drift or a defect.                                        |
| Candidate-only            | The candidate succeeds while the reference returns 404/410; a possible addition.                                    |
| Reference-only            | The reference succeeds while the candidate returns 404/410; a possible removal or discovery/content gap.            |
| Unmatched                 | The endpoints reach different paths, or neither contains the sampled content. Do not compare them as the same page. |
| Inconclusive              | Access, transport or server problems prevented a reliable comparison.                                               |
| Accepted difference       | An explicit config rule matched it; the evidence remains visible.                                                   |

A difference count is **not a defect count**. A smaller image derivative can be intentional if its displayed size and quality remain suitable. A missing dropdown option may be intentional when the candidate hides empty terms. Confirm the reason before accepting it. Production is a reference, not proof of correctness.

Missing or broken image sources can fail candidate health checks. Possible image distortion and document horizontal overflow are advisories: review their element selectors and highlighted screenshots. Intentional `object-fit: cover` or `contain` cropping is excluded from the distortion check; clipped carousel tracks alone do not establish document overflow. These checks also run at the errors level.

Structural comparison needs exactly one captured `<main>` on each side. Missing or ambiguous regions produce a disclosed limitation, not a claim that every local structure was newly added. Location evidence for empty links is retained, but movement alone does not establish an empty-link change.

## 5. Fix, rerun and share

After fixing code or correcting local content, run the same command again. Each invocation creates a new `run-…` directory, so previous evidence remains available. There are no automatic retries within a run. Old run folders are not automatically removed; keep the evidence you need and delete older generated folders yourself.

Zip the **whole run directory** to share it. It contains `index.html`, `summary.md`, paired and diff PNGs where captured, `run.json`, `plan.json` and the discovery matrix. Sending the HTML file alone loses the screenshots. Keep sandbox baselines in `tests/__screenshots__/` separate from these generated reference/candidate captures.

Use **Download Markdown summary** or open `summary.md` for a text summary suitable for a pull request or handover. It includes health failures, behaviour changes, accepted differences, limitations, screenshot guidance and coverage counts. Keep the HTML and raw evidence available for the complete review.

For scripts and CI, exit codes mean:

| Code | Meaning                                                                                                                            |
| ---- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `0`  | Run completed without candidate health failures or unmatched/inconclusive comparisons. Advisory differences can still need review. |
| `1`  | Candidate health checks failed, or setup/configuration failed before the run.                                                      |
| `2`  | Run was incomplete, empty, unmatched or inconclusive; this takes precedence over health failures.                                  |

## Accepting intentional differences

Review a finding before adding a policy. Existing top-level `ignore` settings apply to health checks and route exclusions. Inside `regression`:

* `accept` takes case-sensitive substring signatures, for example `"title on /about/"`. Accepted differences remain in the report.
* `ignoreSelectors` takes CSS selectors for dynamic content, such as a random staff selection. Matching elements are excluded from semantic comparison and masked in screenshots on both sites; candidate health still checks them.

All policies are disclosed. No date, random-content or text suppression is added automatically. Invalid regression keys and selectors fail rather than silently removing evidence.

Expand **Accept this difference in future runs** beside a comparison finding for a JSON fragment. Merge its entry into your existing `regression.accept` array; do not replace the rest of your configuration. For example:

```json
"accept": ["forms on /making-a-difference/"]
```

This accepts all form differences matching that signature, across viewports and future runs, including future changes beyond the one reviewed. Substring matching can also match longer route names. It does not clear HTTP or other health failures. Remove the entry when you want that comparison reviewed again.

For an intentionally removed route, a separate top-level `ignore.routes` entry excludes its checks altogether. Use that only when excluding the route is intended; accepting a status difference alone will not clear its candidate health failure.

## Other settings and limits

| Regression setting                      | Default / purpose                                                                                                                               |
| --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| `viewports`                             | Custom sizes: entries contain `name`, `width`, `height`; preset names can be overridden.                                                        |
| `defaultViewports`                      | New setup selects desktop, tablet and mobile. Existing explicit selections are preserved.                                                       |
| `coverage`                              | `sampled`; choose `exhaustive` to add eligible published public content and public terms, including empty terms. CLI `--coverage` overrides it. |
| `accessibility`                         | `"on"`; set `"off"` to skip axe while retaining the chosen comparison level. CLI `--accessibility` overrides it.                                |
| `defaultLevel`                          | `full` unless set to `errors` or `core`; CLI `--level` overrides it.                                                                            |
| `navigationLimit`                       | 40 production-navigation links, bounded to 0–200; zero disables that supplement.                                                                |
| `timeout`                               | 20,000 ms per operation, configurable from 1,000–120,000 ms.                                                                                    |
| `criticalRoutes`                        | Extra paths to supplement the derived matrix, never replace it.                                                                                 |
| `defaultReference` / `defaultCandidate` | `production` / `local`.                                                                                                                         |

Forms are inspected but not submitted. Generic JavaScript interaction testing (including carousel controls, menus and filter operation) remains deferred. Screenshot diffs are advisory rather than pixel-equality gates. ACF schema helps identify representative surfaces but does not establish that every relationship or rendered component should be populated; complete ACF coverage is not claimed. The runner does not control WordPress/plugin side effects of serving GET requests or booting WP-CLI.

For implementation details, see [Design & Internals](/ecosystem/shakedown/shakedown-design).

## Accessibility and deployment review

Accessibility checks come from Deque's axe-core through `@axe-core/playwright`; Playwright supplies the browser. They check WCAG 2.0/2.1 A and AA rules on the candidate, not differences from production. A serious or critical rating describes accessibility impact and currently contributes a candidate health failure; it does not establish that this release introduced the issue or assign release risk.

To review visual and structural regressions without an accessibility audit:

```sh
npx shakedown regression --level=full --accessibility=off
```

Save `"accessibility": "off"` inside `regression` for a project preference, or use `--accessibility=on` to override it for one run. The default is on for core/full; errors always omits the audit. The report explicitly lists accessibility as not checked when disabled. This switch affects regression mode only, not attached or sandbox passes. Alternatively, `ignore.a11yRules` narrows individual rules.

For deployment review, distinguish candidate application failures, unexpected reference differences, intentional changes and existing accessibility debt. Review incomplete coverage and visual changes even when the exit code is zero. A passing selected suite is evidence for a release decision, not a guarantee that every route, browser or interaction works.

## Identifying accessibility elements

Under **Accessibility: element details**, expand a rule and then an element. Each element includes its selector, escaped HTML, axe's explanation and check data. Contrast checks include measured and required ratios and foreground and background colours when axe provides them.

The close-up outlines the element on a saved screenshot. Use **Open original screenshot** for full-page context. A second full-page overlay is no longer embedded for every element. Highlights are report overlays; they do not modify the tested page or visual baselines. Missing, hidden or ambiguous elements retain their details with an explicit explanation when a highlight cannot be captured.

These are candidate accessibility findings, not proof of a change from production. Existing rule suppressions still apply and remain disclosed. Trial reports also include element evidence, retaining each retry attempt separately. Share the report directory with its images, not just the HTML file.

Older reports cannot recover details that were not saved. Run Shakedown again with the updated version to capture this evidence.

## Choose the testing level and viewports

Start with application health, then expand the scope when you are ready:

```sh
npx shakedown regression --level=errors --viewports=desktop
npx shakedown regression --level=core --viewports=desktop
npx shakedown regression --level=full --viewports=desktop,tablet,mobile
```

| Level    | What runs                                                                                                                                                                                                                                   |
| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `errors` | Candidate HTTP status, PHP/Twig error output, title presence, browser JS/console/request failures and broken/missing image sources; distortion and overflow advisories. No production requests, axe audit or paired comparison screenshots. |
| `core`   | Application checks plus serious/critical axe findings, status/redirect comparisons, form definitions and empty-link differences. Accessibility findings retain element highlights.                                                          |
| `full`   | Core checks plus all axe findings, title/heading/image/layout/structure differences, paired screenshots and advisory pixel diffs for matched pages.                                                                                         |

Levels are cumulative in check coverage. Core changes are review priorities, not proof of a severe regression. Forms are inspected, never submitted; these levels do not claim to test interactive workflows. All profiles retain existing route, request and rule suppressions. Core reports disclose omitted minor/moderate axe findings; the underlying axe engine may still evaluate those rules.

The **Review** filter narrows observations to broken candidate pages, serious accessibility issues, behaviour changes, presentation/content changes, inconclusive observations, or observations with no findings in the selected checks. **Viewport** further narrows the report. Filters do not change totals, exit status or saved evidence.

Built-in viewport presets are desktop **1280×900**, tablet **768×1024** and mobile **390×844**. These are Chromium viewport dimensions, not device/touch emulation. Custom `viewports` entries override preset dimensions or add named sizes. Unknown or duplicate names fail early. Selection order is preserved; feeds run once.

Save defaults inside the existing `regression` object:

```json
"defaultLevel": "full",
"coverage": "exhaustive",
"defaultViewports": ["desktop", "tablet", "mobile"],
"accessibility": "off"
```

Merge these settings into the theme's existing `shakedown.config.json`, inside `regression`, keeping its reference and candidate URLs. This example deliberately skips accessibility. Then run `npx shakedown regression` from the theme directory. `--routes` remains a command-line-only focus option; it has no saved config key.

CLI flags override defaults for one run without editing configuration. New `init` configurations select desktop, tablet and mobile. Existing configurations retain their explicit `defaultViewports` or `viewports` selection. With neither setting, all three presets are selected. The level defaults to full.

Each command creates a separate report. Limited runs prominently show omitted checks; an errors-only pass does not mean the site passed full regression. Candidate-only errors runs use the derived route matrix without the production navigation supplement, and classify visited routes as `candidate-checked`. The errors level still uses the regression environment configuration.

## Choose coverage or focus on a route

Testing level determines **which checks** run. Coverage determines **which routes** are selected; viewports determine **which sizes** are captured. A full run is still sampled unless you change coverage.

```sh
npx shakedown regression --level=full --coverage=exhaustive
```

Exhaustive adds eligible published public singles/pages and public taxonomy terms, including empty terms which may have their own landing-page content. Route exclusions still apply. It does not exhaustively test author/date archives, pagination, arbitrary query combinations, remote-only content or interactions. Inventory failure makes an exhaustive run incomplete; it cannot silently claim complete coverage. Large inventories across three viewports can take considerably longer than a sampled run.

The report counts known routes not selected and lists selected observations not visited. Download `run.json` for the complete omitted-content inventory; HTML retains the count and the command for exhaustive coverage. An unavailable inventory means omitted-route counts are unknown. A page absent from the tested observations has not passed.

To investigate one page quickly:

```sh
npx shakedown regression --level=full --viewports=desktop --routes=/training-type/health-service-modelling-associates-programme-hsma/
```

For several pages, use `--routes=/about/,/contact/`. These are exact paths, not patterns. Quote the argument when a query contains shell characters, and encode literal commas as `%2C`. Shakedown selects only discovered, allowed routes; eligible inventoried content can be selected even when sampling would omit it. Unknown, ignored or unsafe paths fail explicitly. Production homepage discovery may still run to resolve navigation routes. The report discloses the focused selection; remove `--routes` to restore broader coverage.

## Review repeated changes and screenshot diffs

**Repeated changes** groups identical recorded changes and recognised title separator changes. It also groups source changes for images uniquely matched by alt text with equal rendered dimensions, and repeated landmark role/label changes. Ambiguous images are not guessed for grouping. Expand a group to open each route and viewport. Grouping does not suppress findings or decide whether they are improvements; other changes on the same pages still need review.

Image evidence omits equal comparison values using duplicate-aware matching, so an insertion or reordering does not make every later image appear changed. The remaining values are **changed or unmatched**, not assumed one-to-one pairs. Source, alternative text, dimensions and rendering metadata remain reviewable. Position-only image movement is described as layout evidence and kept out of the image-content element panel; positions remain in raw JSON and visual comparisons. Empty-link movement alone is not a content change. Legacy or mismatched evidence arrays retain their full captured group with an explicit explanation.

**Repeated image advisories** groups identical image-distortion findings using recorded messages, element targets, dimensions and suppression state. Each group shows one representative and affected-route/observation counts. Different cases remain separate. This reduces repeated footer-logo findings without changing page verdicts or deleting raw evidence. Distortion is still a candidate advisory: it does not yet classify a finding as new, unchanged, worsened or resolved relative to production.

**Technical evidence** shows unmatched values rather than full arrays of unchanged members. Use `run.json` for complete evidence. Acceptance scope is explained once under **Accepting intentional differences**; each finding links there and supplies its config snippet. Transport panels are omitted when there are no restrictions, advisories or capture limitations; successful lazy-loading bookkeeping remains in raw evidence. These presentation changes do not alter checks, counts or exit codes.

In full runs, **Appearance changed** links to Playwright's visual comparison viewer for matched pages. Inspect the reference, candidate and highlighted diff at a readable scale. Shakedown uses Playwright's `toMatchSnapshot` comparator with a colour threshold of 0.2 and zero allowed differing pixels. Current reports do not use the older changed-area percentage or 16/255 channel tolerance. Font rendering, moving content and capture timing can still contribute; a mismatch requests review rather than diagnosing its cause or severity.

Screenshots retain the selected viewport width while capturing the full document height. Shakedown does not inject overflow styles or remove off-screen elements. Horizontal scrolling is checked separately. Large document dimensions or an element positioned outside the viewport alone do not establish a defect: root clipping and ancestor overflow clipping are considered before suggesting contributors. The raw JSON retains document/viewport widths, measured horizontal scroll range and element positions. Suggested contributors are not proven causes.

Diffs exceeding 16 million pixels, missing captures and decoding failures are disclosed as unavailable. Originals remain available when captured. Screenshot changes request review but do not themselves fail the process exit code or create baselines. Existing saved reports remain as generated; new runs use the updated report format from the installed Shakedown version.


# Design & Internals

Why Shakedown is built the way it is — and how each layer works, with the third-party tools it stands on and where their documentation lives.

The [Shakedown guide](/ecosystem/shakedown) explains installation, initialisation and configuration. The [Regression testing walkthrough](/ecosystem/shakedown/shakedown-regression) covers production comparisons and reviewing findings. This page explains **why it's built this way** and **how each layer works** — useful when you're extending it, debugging it, or deciding whether to trust it.

## 🤔 Design decisions

### Why derived, not authored

Authored e2e suites die of maintenance: someone lists the URLs, writes the assertions, and forgets to update both. PressGang themes are different — the route surface is *declared* in `config/`, so the suite can be generated from the theme itself and never drifts. Add a CPT, gain its tests. Authored specs are reserved for the things derivation can't know: user journeys. This is the central bet; everything else follows from it.

### Why Playwright

Playwright is what WordPress core itself uses — all core and Gutenberg browser tests migrated from Puppeteer in 2023 ([announcement](https://make.wordpress.org/core/2023/10/16/wordpress-core-is-now-using-playwright-for-all-browser-based-tests/)). Practically it gives us, in one dependency: auto-waiting locators, an HTTP request client (pass 00 needs no browser), parallel workers, trace capture for post-mortem debugging, and first-party visual comparison — plus official axe-core bindings. The strongest alternative, [wp-browser/Codeception](https://wpbrowser.wptestkit.dev/), is excellent for PHP-side integration tests but its browser layer (WebDriver) is a generation behind, and a browser harness belongs in Node where the browser tooling lives. Docs: [playwright.dev](https://playwright.dev/docs/intro).

### Why an mu-plugin for the observer

The observer must run on **every request**, load **before plugins**, and require **no database state** — activation is a DB row, and Shakedown never writes to a real database. `mu-plugins/` is WordPress's canonical mechanism for exactly this: always loaded, can't be deactivated, and Composer-native (`"type": "wordpress-muplugin"` routes packages there via installer-paths). The alternatives are worse: drop-ins (`db.php`, `object-cache.php`) are single-occupancy and fought over by caching plugins; editing `wp-config.php` means mutating a file we don't own. Today the observer is installed **only into sandboxes**, assembled fresh each run.

### Why seed and fake — hence Muster

Three reasons real content can't be the fixture:

1. **Determinism.** Visual snapshots and stable selectors need repeatable content across runs and machines. [Muster](/ecosystem/muster) supplies the seeded [Faker](https://fakerphp.org/) sequence, while Shakedown separately pins one fixture epoch shared by post dates and ACF/Victuals date generation. Neither generated values nor relative dates consult the machine clock.
2. **Denominators.** Real content only exercises the states editors happen to have created. Fixtures derived from `acf-json` exercise the states that *can exist* — including the all-important **minimal state** (required fields only), where empty-link and missing-image bugs live. Real content found one such bug on BHP by luck; derivation finds them systematically.
3. **The hard rule.** Nothing ever writes to a real site's database. Seeding is therefore only possible in an environment that is disposable *by construction* — which is why Muster and the sandbox arrived together.

Muster borrows useful seeder ergonomics from several frameworks, then adapts them to WordPress: fluent builders persist through core APIs, natural-key lookups avoid blind inserts, and `--seed=N` controls generated fixture values without introducing Models or an ORM.

### Why the sandbox is SQLite

The [SQLite Database Integration plugin](https://wordpress.org/plugins/sqlite-database-integration/) (the WordPress Performance team's own project, with a real MySQL-parser driver since 2025) lets a genuine PHP WordPress run with a single database *file* — no MySQL server, no Docker, nothing shared. The sandbox symlinks your **code** read-only and owns its **state** (config, uploads, database) in a temp dir: Laravel's in-memory test database, translated to WordPress. Isolation isn't assumed — every boot queries a witness endpoint and refuses to test unless `ABSPATH`, the content dir, and the database all resolve inside the temp directory.

{% hint style="warning" %}
**Hard-won:** PHP resolves `__DIR__` through symlinks, so entry PHP files are *copied* rather than symlinked — a symlinked `wp-load.php` would silently load the real site's config instead of the sandbox's.
{% endhint %}

### Why CI

A suite that only runs on one laptop rots; a gate on every push makes silent regressions unmergeable. The sandbox is what makes CI honest *and* cheap: because it needs only the theme repo (core downloaded bare, parent + plugins provisioned by the theme's own Composer installer-paths, fixtures derived), there's no database dump, no site bundle, no Docker — a run costs pennies on GitHub's Linux runners and finishes in minutes.

***

## ⚙️ How it works, layer by layer

### Route derivation — Capstan

When [Capstan](/ecosystem/capstan) is installed, Shakedown shells out to it:

{% code title="Terminal" %}

```bash
wp capstan matrix --resolve --format=json --samples=2 --search=research
```

{% endcode %}

```json
{ "routes": [ {
    "url": "https://mysite.test/events/",
    "kind": "archive:event",
    "expect": 200,
    "template": "dispatch.php",
    "controller": "MySite\\Controllers\\EventsController"
} ] }
```

`--resolve` replays each URL through Capstan's request simulator to attach the **oracle**. In testing, an *oracle* is whatever authoritatively tells you what the correct answer **should** be, so a test can judge what actually happened. Without one, a test can only check generic properties ("returned 200, no errors"); with one, it checks *intent*. Here the oracle is PressGang's own routing logic, replayed without rendering: for each URL it declares the template and controller the framework means to use — and at runtime the observer reports what really rendered, so the two can be compared. A page that quietly falls back to `index.php` still returns a healthy 200; only the oracle comparison catches it.

Without Capstan, a bundled `matrix.php` derives the same route families via `wp eval-file`, minus the oracle. Before any derivation, `wp capstan doctor --format=json` runs as a pre-flight — 11 deterministic config checks; failures abort the run before a browser launches.

### Runtime observation — the observer mu-plugin

Inside the sandbox, every response carries headers describing what actually happened:

```
X-Shakedown-Template: dispatch.php
X-Shakedown-Controller: events_controller
X-Shakedown-Php-Issues: 0
```

Pass 00 compares the first two against the oracle (so a route silently falling back to `index.php` is a hard failure), and fails any route whose PHP-issue count is non-zero — notices are counted by an error handler even when display and logging are off.

Getting those headers out is fiddlier than it looks, and the timing is the whole trick. The observer buffers output for the entire request, then writes the headers from WordPress's `shutdown` action at **priority 0** — ahead of `wp_ob_end_flush_all` at priority 1, which is what flushes the buffer and commits the response. A plain `register_shutdown_function()` cannot work here: WordPress registers *its* shutdown handler at `wp-settings.php:166`, while mu-plugins don't load until `:498`, so WP's always runs first and `headers_sent()` is already true. Everything raised during rendering is therefore counted; anything raised later, by shutdown callbacks at priority 1 or beyond, is not — the honest cost of having to commit headers before the body goes out.

Because every one of those assertions is guarded on its header existing, an observer that stops answering would turn them all into no-ops that report success. So a sandbox run also asserts that the observer answered at all: silence is a failure, not a skip.

### The passes — Playwright

For attached and sandbox tests, Shakedown runs Playwright with a packaged config; the invocation directory is the *workspace* (reports, matrix, and baselines land there; a `tests/e2e/` dir joins the run as the journeys project). Pass 00 uses Playwright's [APIRequestContext](https://playwright.dev/docs/api-testing) (no browser — whole-site sweep in seconds); passes 01–03 drive Chromium. Failures retain a **trace** — open with `npx playwright show-trace <trace.zip>` for a time-travel replay ([trace viewer docs](https://playwright.dev/docs/trace-viewer)). The developer-grade HTML report lands in `playwright-report/` ([reporter docs](https://playwright.dev/docs/test-reporters)).

### Accessibility — axe-core

Pass 02 uses Deque's official [`@axe-core/playwright`](https://playwright.dev/docs/accessibility-testing):

```js
new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).exclude('iframe').analyze()
```

Output is a violations array — rule id, impact, offending nodes, and a `helpUrl` into [Deque University's rule reference](https://dequeuniversity.com/rules/axe/) explaining each fix. Shakedown's gate: `serious`/`critical` fail the route; `moderate`/`minor` print as advisories (promote them once the serious set is clean). Iframe *contents* are excluded — YouTube's player chrome is not your remediation surface.

### Visual regression — Playwright snapshots

Pass 03 is Playwright's built-in [`toHaveScreenshot`](https://playwright.dev/docs/test-snapshots): full-page, animations disabled, `<time>` elements masked, `maxDiffPixelRatio: 0.001`. Baselines are written to the theme's `tests/__screenshots__/{platform}/` — per-platform because font rendering differs between macOS and Linux, so local and CI baselines coexist. On a diff, `test-results/` contains expected/actual/diff images. Refresh intentionally with `npx shakedown sandbox --update-snapshots`; review the image changes in the PR like any other diff.

### The Trial Report — custom reporter

A small [Playwright reporter](https://playwright.dev/docs/test-reporters#custom-reporters) collects every result and writes two artifacts to `.shakedown/`:

* `run.json` — machine-readable: `{generated, target, results: [{pass, kind, url, status, error}]}` — the seed for future coverage tooling.
* `trial-report.html` + `screenshots/` — the client-readable handover: summary strip, a screenshot preview per route, a route × pass matrix, and failures in plain English. Self-contained folder; zip it, attach it, email it.

### Fixtures — Muster's seeding pipeline

`shakedown sandbox` runs a bundled PHP script via WP-CLI inside the sandbox: [`AcfJson`](/ecosystem/muster) reads the theme's `acf-json/`, extracts each group's seedable location (post type / page template / options page), and `AcfValueGenerator` produces values for \~25 ACF field types — recursing through repeaters, groups, and flexible content (one row per layout, so every layout renders at least once). Media fields get generated placeholder images (colour derived from the slug — deterministic), relational fields get stub posts/terms. Two variants per group: `populated` and `minimal`. Options-page groups seed once so the site chrome (header/footer) renders fully. One explicit seed and fixture epoch are passed into Muster; the same clock pins both generated ACF dates and fixture post dates.

### The sandbox assembly — SQLite + wp server

The [SQLite Database Integration plugin](https://github.com/WordPress/sqlite-database-integration) is pinned to an exact version, fetched from wordpress.org, verified against a known SHA-256, and wired in via its `db.copy` drop-in template (`DB_DIR`/`DB_FILE` constants point at the temp dir). The cache is keyed by version and staged-then-renamed, so bumping the pin invalidates it by construction and an interrupted download can never become the cache — `latest-stable` would have meant two machines assembling sandboxes on two different database layers, underneath every visual baseline. The site is served by [`wp server`](https://developer.wordpress.org/cli/commands/server/) — WP-CLI's built-in PHP server with a WordPress-aware router — on an OS-assigned ephemeral port. `wp core install`, theme activation, and permalink setup all run against the throwaway database.

### CI — the reusable workflow

The [workflow](https://github.com/pressgang-wp/pressgang-shakedown/blob/main/.github/workflows/shakedown.yml) checks the theme out *into* a WordPress-shaped tree, then: [`shivammathur/setup-php`](https://github.com/shivammathur/setup-php) (PHP + WP-CLI + Composer), `wp core download --skip-content`, `composer install` in the theme (parent + plugins land via installer-paths; ACF Pro credentials via the `COMPOSER_AUTH` secret — [Composer auth docs](https://getcomposer.org/doc/articles/authentication-for-private-packages.md)), the workflow's pinned `muster-ref` fetched for fixtures, Capstan installed for the oracle, then `npx shakedown sandbox`. Composer and [Playwright browser caches](https://playwright.dev/docs/ci#caching-browsers) keep warm runs fast; the Trial Report uploads as an artifact either way.

### Project setup

`shakedown init` belongs to Shakedown rather than Capstan: it owns the runner's configuration and artifact ignore rules. `lib/init.mjs` runs before target resolution, discovers WordPress through a bounded set of paths, optionally reads its home URL through WP-CLI, and collects named regression origins. Interactive prompts and explicit flags feed the same validation. Non-interactive invocations never wait for input. Existing local or ancestor configs are preserved; relative site paths resolve against the config directory. Setup writes only project-local config and ignore files, never WordPress data, baselines or dependencies.

Configuration discovery and the workspace are distinct. The nearest ancestor config supplies target settings; relative `sitePath` values are anchored there. Reports and consumer journeys remain anchored to the invocation directory, so users should run from the consumer project root even when the executable is installed through a local link to the Shakedown checkout.

### Regression — one discovery matrix, two observed environments

`lib/target.mjs` retains local discovery and resolves named reference/candidate origins from the same target's `regression` object. `lib/regression-plan.mjs` creates a separate versioned paired plan preserving kind, expected status, oracle metadata, exact path/query and provenance. It rejects foreign-origin and unsafe routes, with exclusions disclosed. Normal attached/sandbox matrices are not rewritten for remote origins.

`lib/regression.mjs` runs Capstan/fallback derivation plus supplementary families inside a unique evidence directory. Production homepage navigation adds at most a configured number of same-origin links; it is explicitly a sample, not a full inventory. Sitemaps are deferred to avoid quietly growing a second crawler. Missing paths are never inferred solely from a sampled matrix. Shared paths compare directly; different redirect destinations remain unmatched.

`lib/health.mjs` holds correctness checks shared with passes 00–02. Production parity is never a correctness assertion. Candidate health blocks, while semantic and visual differences begin advisory until narrower rules have field evidence. The existing sandbox baseline pass and consumer journeys are never collected by regression. Each route, viewport and side gets a fresh context, without retries; an unsuccessful capture cannot be overwritten by a successful retry.

Before collecting evidence, bounded scrolling triggers lazy images, then returns to the top. The report records scroll limits and pending images; broken lazy images join health checks when the page bottom was reached. The runner owns SIGINT/SIGTERM handling so interruption preserves an explicit incomplete report before browser teardown.

`lib/regression-browser.mjs` enforces anonymous GET-only requests below JavaScript. Redirects are inspected before following; top-level and iframe navigation cannot leave the selected origin. Non-GET requests, WebSockets, service workers, known administrative/action URLs and cookies/authorization are blocked. A separate HTTP context is necessary: Playwright's `route.fetch()` populates the browser cookie jar before a caller strips response cookies. An adversarial browser fixture proves that requests cannot bypass these restrictions. Blocked traffic is evidence because these restrictions can change the rendered page. As in attached mode, this does not control WordPress/plugin side effects of serving a GET or booting WP-CLI.

Semantic evidence uses normalized title/H1, landmarks and positions, visible form schemas, image URLs and natural/displayed dimensions, empty links/headings and a basic content structure sequence. No CSS class is assumed to mean a card. Hidden form state and exact body text are excluded; dynamic selectors and accepted finding signatures require explicit disclosed policies. Different image identities or sampled posts are not relabelled as equivalent content.

`lib/regression-report.mjs` writes a distinct Regression Report alongside JSON and runtime PNGs. Candidate health, differences, additions/removals, accepted differences and inconclusive/unmatched evidence stay separate. Reports are initialized before discovery, updated during work and retained per run; a failed discovery cannot leave an old all-clear looking current. Interstitial titles trigger an explicit incomplete result and stop additional route traffic. These heuristics are evidence of uncertainty, not proof that an access barrier exists.

The summary counts route/viewport observations, not unique paths or root causes. A complete execution can still exit 1 for health failures or 2 for unmatched or inconclusive evidence. Advisory differences alone can exit 0 and still require review. Regression reports are opened directly as HTML; the Playwright report viewer for the whole suite and ordinary Trial Report belong to the other execution path. Regression findings additionally link to a generated Playwright visual comparison viewer for each compared screenshot pair.

Paired regression screenshots use the selected viewport width and full document height without changing page CSS. Playwright `toMatchSnapshot` compares disposable reference/candidate captures (threshold 0.2, zero allowed differing pixels); these captures never become approved baselines. Horizontal scroll range and clipped element evidence are assessed separately from the visible screenshot comparison.

The regression `accessibility` setting accepts `"on"` (default) or `"off"`, with a CLI override. Disabling it leaves visual/structural comparison intact and discloses the omitted audit. Axe findings describe candidate accessibility health rather than a reference-to-candidate accessibility comparison.

ACF's regression role is deliberately narrower than fixture generation. Locations can identify representative content surfaces, but optional relationships, conditional groups and nested flexible content do not establish a universal field-to-DOM contract. A future read-only coverage inventory and explicit PressGang markup conventions can close that gap without duplicating Capstan or Muster. The initial release does not seed existing content or claim complete ACF coverage.

### Compact regression report projections

`lib/report-compact.mjs` builds reviewer-facing projections without mutating the saved run. Technical arrays cancel equal values as multisets; unmatched values are not guessed pairs. Image-content panels exclude position-only movement, while screenshot/layout comparisons and raw coordinates preserve it. Legacy captures without usable array mappings retain their full evidence group.

Identical candidate image-aspect-ratio advisories share a representative by message, target, dimensions and suppression state. Route counts remain visible; raw findings, verdicts and exit status stay unchanged. The complete omitted-route inventory stays in JSON. Element evidence uses one close-up plus an original-image link, acceptance guidance is global, and empty transport panels are omitted.

### Planned work versus current features

Regression currently runs Chromium. Optional Firefox/WebKit coverage, richer regression traces and ARIA snapshot evaluation are roadmap items, not supported configuration options. See `docs/ROADMAP.md` in the Shakedown repository. The Shakedown repository's `docs/CI-DEPLOYMENT-SPEC.md` is a proposal for PR reports, gate policy, approvals and notifications. It does not add a shipped deployment gate, Slack integration or retained-reference importer. The existing reusable workflow remains the sandbox testing lane.


# A Note from the Author

I’m [Benedict Wallis](https://benedict-wallis.com/), a senior freelance web developer based in Bristol, UK, and the author of PressGang. For over 20 years, I’ve worked with businesses and agencies to build websites, ecommerce stores and custom web applications.

## ❤️ A Labour of Love

PressGang is a labour of love more than fifteen years in the making.

It grew out of a simple goal: to make WordPress development more enjoyable, more maintainable, and faster to build and prototype with — without fighting the platform itself.

What started as a personal toolkit to consolidate reusable patterns across client projects gradually evolved into something more formal. Much of that work happened in delivery-focused agency environments, where clarity, consistency, and clean handover genuinely matter.

From early on, PressGang stood on the shoulders of giants — particularly [Timber](https://timber.github.io/docs/v2/) + [Twig](https://twig.symfony.com/doc/3.x/) 🌲 — whose separation of PHP logic from presentation fundamentally reshaped how I think WordPress themes should be structured.

In parallel, [Laravel](https://laravel.com/) strongly influenced my preference for explicit structure and convention over configuration.

## 🧠 Influences & Approach

PressGang doesn't try to turn WordPress into Laravel, nor does it attempt to reshape WordPress into something it isn't.

Instead, it borrows what translates well:

* Namespaced, PSR-4 autoloaded PHP
* Explicit configuration
* Clear architectural boundaries

All while working entirely within WordPress's native theme and runtime model, not around it ⚙️.

There's no attempt to hide WordPress, replace it, or abstract it beyond recognition — the goal is to work *with* WordPress, but in a way that feels modern, explicit, and maintainable.

## 🏗️ Where It Is Today

Today, PressGang is a modern WordPress parent theme framework designed for bespoke, long-lived professional websites.

At its core:

* Composer-based foundations
* Controller-driven context passed cleanly to Twig via Timber
* Clear extension points intended for child themes

It's deliberately opinionated but lightweight, avoiding heavy abstraction and hidden "magic" in favour of clarity and developer control.

## 🌍 Real-World Use

PressGang has been shaped almost entirely by real production work.

It's been used across hundreds of projects at several agencies I've worked with — many of whom adopted it and continued using it long after I'd moved on.

It has powered themes for:

* Global organisations
* Large brands
* Ecommerce stores
* SMEs
* Grassroots projects — including my local cyclocross league 🚴‍♂️

That long-term, real-world usage has been its strongest form of validation.

## 🤝 Working Together

Alongside maintaining PressGang, I offer [bespoke WordPress development](https://benedict-wallis.com/services/freelance-wordpress-developer/) and [WooCommerce development](https://benedict-wallis.com/services/freelance-woocommerce-developer/), including custom themes, plugins and integrations.

If you need help building with PressGang or improving an existing site, [explore my work and get in touch](https://benedict-wallis.com/).

## 🔗 Links

* [PressGang on GitHub](https://github.com/pressgang-wp/pressgang)
* [Read the docs](https://docs.pressgang.dev/)


