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

`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 %}


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

### 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                         |
| `TaxonomyController` | varies          | Taxonomy archive pages                       |
| `CommentsController` | `comments.twig` | Comments template                            |
| `NotFoundController` | `404.twig`      | 404 error page                               |

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

## 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;

class FrontPageController extends PageController {

    /**
     * Template contract for front-page.twig: latest news and upcoming
     * 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 $this->news ??= /* ... query ... */;
    }

    protected function get_events(): array {
        return $this->events ??= /* ... query ... */;
    }
}
```

{% 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 %}

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


# 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`.


# 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 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
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 %}

## 🚢 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.


# 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="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.
{% 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 %}
{% 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 %}

***

## ↕️ 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 %}
{% 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              |
| `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                                |
| `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                                              |
| `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. ⚓🚢


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

{% 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.
* [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).
* 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" %}
**The one-liner:** run `npx shakedown` inside your theme and, in about a minute, every page your site serves has been checked for errors, broken assets, and accessibility problems — in a real browser.
{% endhint %}

## 🧰 Commands at a glance

Shakedown runs in one of two **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           |
| **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`                            | Attached | Runs every pass against your local site                           |
| `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`                         | Either   | Playwright's UI / watch mode, for fixing failures                 |
| `npx playwright show-report`               | Either   | Opens the last HTML report                                        |

## 📦 Install

You need Node 20+, [WP-CLI](https://wp-cli.org/), and your site running locally (any server — Herd, Valet, DDEV, MAMP… it's just a URL). From inside your theme:

{% code title="Terminal" %}

```bash
npm i -D @pressgang-wp/shakedown
npx playwright install chromium   # once per machine
```

{% endcode %}

## ⚡ First trial

{% code title="Terminal" %}

```bash
npx shakedown
```

{% endcode %}

That's it — no config. Shakedown walks up from your theme to find `wp-config.php`, asks WP-CLI for the site URL, enumerates every route, and checks them all. Want to see the map before sailing?

{% 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. Skips politely until baselines exist.                 |

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

Every run writes `.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.

## ⚙️ Configuration

None required. An optional `shakedown.config.json` in the theme handles the exceptions:

{% 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 theme's `tests/e2e/` — when present, they run alongside the derived passes.

### 🔇 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.

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 from inside the theme (or anywhere below `wp-config.php`), or set `sitePath` in the config.
* **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 %}


# 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) tells you what to run. 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

Shakedown runs Playwright with a packaged config; your theme 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.


# A Note from the Author

## ❤️ 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.

## 🔗 Links

* [PressGang on GitHub](https://github.com/pressgang-wp/pressgang)
* [Read the docs](https://docs.pressgang.dev/)


