# Quartermaster

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

***

## 🧠 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');
```

{% endcode %}

***

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

Field defaults to `slug`. Operator defaults to `IN`. Multiple calls produce AND relation.

</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. ⚓🚢


---

# Agent Instructions: Querying This Documentation

If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://docs.pressgang.dev/ecosystem/quartermaster.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
