> For the complete documentation index, see [llms.txt](https://docs.pressgang.dev/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.pressgang.dev/ecosystem/quartermaster.md).

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

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

***

## 🏷️ Taxonomy Queries

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

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

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

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

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

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

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

{% endcode %}

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

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

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

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

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

{% endcode %}

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

***

## 📅 Date Queries

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

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

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

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

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

{% endcode %}

***

## 📄 Pagination and Performance

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

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

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

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

{% endcode %}

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

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

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

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

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

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

{% endcode %}

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

***

## ↕️ Ordering

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

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

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

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

{% endcode %}
{% endtab %}

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

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

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

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

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

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

***

## 🔍 Search

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

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

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

{% endcode %}
{% endtab %}

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

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

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

{% endcode %}

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

***

## 🏷️ Terms — Quick Start

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

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

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

{% endcode %}
{% endtab %}

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

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

{% endcode %}
{% endtab %}

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

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

{% endcode %}

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

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

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

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

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

{% endcode %}
{% endtab %}

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

```php
// Find terms by slug
$genres = Quartermaster::terms('genre')
    ->slug(['rock', 'jazz'])
    ->hideEmpty(false)
    ->get();

// Find terms by name
$terms = Quartermaster::terms('category')
    ->name('Tutorials')
    ->get();

// Return IDs only
$ids = Quartermaster::terms('category')
    ->fields('ids')
    ->get();
```

{% endcode %}
{% endtab %}

{% tab title="🌲 Timber" %}
{% code title="Controller" lineNumbers="true" %}

```php
$timberTerms = Quartermaster::terms('category')
    ->hideEmpty()
    ->orderBy('name')
    ->timber();

// Returns array of Timber\Term objects
```

{% endcode %}

With the [term-filter fix](https://github.com/pressgang-wp/pressgang-quartermaster/pull/1) (commit `49d5787` or a descendant), the term `timber()` terminal fetches through WordPress `get_terms()` and converts each term individually. It preserves final result filters, plugin ordering and empty results, while retaining Timber class mapping. Scalar field projections retain their values and keys; invalid taxonomies and unsupported count results raise `RuntimeException`.

Check the installed dependency revision before relying on this behaviour. Older versions query through Timber directly and may bypass a plugin's final term ordering filter. Avoid `Timber::get_terms([])` as a conversion shortcut: on Timber 2.5.1 an empty list is interpreted as a query.
{% endtab %}

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

```php
// Paginate terms manually
$terms = Quartermaster::terms('category')
    ->page(2, 10)   // page 2, 10 per page
    ->get();

// Or set limit and offset directly
$terms = Quartermaster::terms('category')
    ->limit(10)
    ->offset(20)
    ->get();
```

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

***

## 🔗 Binding Query Vars

One of Quartermaster's most powerful features — bind URL query parameters directly to query clauses. Perfect for archive filtering, search pages, and faceted navigation.

{% hint style="info" %}
Nothing reads query vars unless you explicitly call `bindQueryVars()`. No smuggling, no hidden defaults.
{% endhint %}

```mermaid
graph LR
    A["🌐 URL query vars"] --> B["bindQueryVars()"]
    B --> C["🔗 Bind / Binder"]
    C --> D["⚓ Quartermaster args"]
```

Two styles are supported — both compile to the same binding map.

{% tabs %}
{% tab title="Map style (Bind::\*)" %}
{% code title="Controller" lineNumbers="true" %}

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

$q = Quartermaster::posts('route')->bindQueryVars([
    'paged'          => Bind::paged(),
    'orderby'        => Bind::orderBy('date', 'DESC', ['title' => 'ASC']),
    'shape'          => Bind::tax('route_shape'),
    'difficulty'     => Bind::tax('route_difficulty'),
    'min_distance'   => Bind::metaNum('distance_miles', '>='),
    'max_distance'   => Bind::metaNum('distance_miles', '<='),
    'search'         => Bind::search(),
]);
```

{% endcode %}
{% endtab %}

{% tab title="Fluent style (Binder)" %}
{% code title="Controller" lineNumbers="true" %}

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

$q = Quartermaster::posts('route')->bindQueryVars(function (Binder $b): void {
    $b->paged();
    $b->orderBy('orderby', 'date', 'DESC', ['title' => 'ASC']);
    $b->tax('district');                           // district -> district
    $b->tax('shape', 'route_shape');               // shape -> route_shape
    $b->tax('difficulty', 'route_difficulty');
    $b->metaNum('min_distance')->to('distance_miles', '>=');
    $b->metaNum('max_distance')->to('distance_miles', '<=');
    $b->search('search');
});
```

{% endcode %}

{% hint style="info" %}
If no taxonomy is provided, Binder assumes the taxonomy name matches the query var key.
{% endhint %}
{% endtab %}
{% endtabs %}

### Available bindings

| Binding              | Purpose                                                                      | Empty handling            |
| -------------------- | ---------------------------------------------------------------------------- | ------------------------- |
| `Bind::paged()`      | Pagination from query var                                                    | Skips if ≤ 0              |
| `Bind::tax()`        | Taxonomy filter                                                              | Skips if empty or null    |
| `Bind::orderBy()`    | Sort field + direction with per-field overrides                              | Falls back to default     |
| `Bind::metaNum()`    | Numeric meta comparison                                                      | Skips if null or empty    |
| `Bind::search()`     | Search query                                                                 | Skips if empty; sanitised |
| `Bind::relevanssi()` | Relevanssi-aware search (requires [Relevanssi](https://www.relevanssi.com/)) | Skips if empty; sanitised |

{% hint style="success" %}
Every binding attempt is logged in `explain()` output — including whether it was applied, skipped, and why. Bound values are redacted for safety; only type shapes are shown.
{% endhint %}

***

## 🗓️ Common Pattern: Meta Date vs Today

Filtering by a meta date (e.g. upcoming vs past events) is a very common WordPress pattern:

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

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

$q = Quartermaster::posts()
    ->postType('event')
    ->status('publish')
    ->whereMetaDate('start', $isArchive ? '<' : '>=')
    ->orderByMeta('start', $isArchive ? 'DESC' : 'ASC');
```

{% endcode %}

This keeps intent explicit — `whereMetaDate(...)` adds a `meta_query` DATE clause while `orderByMeta(...)` controls ordering separately. No hidden assumptions. No barnacles. ⚓

***

## 🔀 Conditional Queries

`when()`, `unless()`, and `tap()` keep fluent chains readable without introducing magic or hidden state. None of them read globals or add defaults.

{% tabs %}
{% tab title="when()" %}
Runs a closure when the condition is true:

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

```php
$q = Quartermaster::posts('event')
    ->when($isArchive, fn ($q) =>
        $q->whereMetaDate('start', '<')->orderByMeta('start', 'DESC')
    )
    ->when(! $isArchive, fn ($q) =>
        $q->whereMetaDate('start', '>=')->orderByMeta('start', 'ASC')
    );
```

{% endcode %}

Or with an else clause — cleaner when conditions are binary:

{% code title="Controller" %}

```php
$q = Quartermaster::posts('event')
    ->when(
        $isArchive,
        fn ($q) => $q->orderBy('date', 'DESC'),
        fn ($q) => $q->orderBy('date', 'ASC'),
    );
```

{% endcode %}
{% endtab %}

{% tab title="unless()" %}
Inverse of `when()` — `unless($x)` is `when(!$x)`:

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

```php
$q = Quartermaster::posts('event')
    ->unless($isArchive, fn ($q) =>
        $q->whereMetaDate('start', '>=')->orderByMeta('start', 'ASC')
    );
```

{% endcode %}
{% endtab %}

{% tab title="tap()" %}
Always runs a closure, for builder-level logic without breaking the chain:

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

```php
$q = Quartermaster::posts('event')
    ->tap(function ($q) use ($debug) {
        if ($debug) {
            $q->noFoundRows();
        }
    })
    ->when(!empty($authorIds), fn ($q) => $q->whereAuthorIn($authorIds))
    ->status('publish')
    ->paged(25);
```

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

{% hint style="info" %}
All three are recorded in `explain()` for debuggability.
{% endhint %}

***

## 🔌 Macros

Macros let you register project-specific fluent methods without bloating the core API. They are opt-in — use them for patterns that repeat across your project.

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

```php
Quartermaster::macro('orderByMenuOrder', function (string $dir = 'ASC') {
    return $this->orderBy('menu_order', $dir);
});

Quartermaster::macro('published', function () {
    return $this->status('publish');
});

// Now use them anywhere
$posts = Quartermaster::posts('page')
    ->published()
    ->orderByMenuOrder()
    ->get();
```

{% endcode %}

{% hint style="warning" %}
Macros should call existing Quartermaster methods — avoid mutating internal args directly. Macro invocations are recorded in `explain()` as `macro:<name>` for debuggability.
{% endhint %}

Both builders (`Quartermaster` and `TermsBuilder`) support macros independently. Use `flushMacros()` in tests to clean up.

***

## 🪝 Escape Hatch: `tapArgs()`

When you need to set an arg that Quartermaster doesn't have a dedicated method for, use `tapArgs()` to manipulate the raw args array while preserving the fluent chain:

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

```php
$q = Quartermaster::posts('event')
    ->status('publish')
    ->tapArgs(fn (array $args) => array_merge($args, [
        'cache_results' => false,
        'suppress_filters' => true,
    ]));
```

{% endcode %}

{% hint style="info" %}
The callback receives the current args array and must return the full replacement array. `tapArgs()` is recorded in `explain()` for debuggability.
{% endhint %}

***

## 🔍 Debugging and Introspection

{% tabs %}
{% tab title="toArgs()" %}
Inspect the generated WordPress args array at any point in the chain:

{% code title="Controller" %}

```php
$args = Quartermaster::posts()
    ->postType('event')
    ->status('publish')
    ->whereMeta('featured', '1')
    ->toArgs();

// array(3) {
//   'post_type'   => 'event',
//   'post_status' => 'publish',
//   'meta_query'  => [['key' => 'featured', 'value' => '1', 'compare' => '=']]
// }
```

{% endcode %}
{% endtab %}

{% tab title="explain()" %}
Inspect args **plus** the full call history, warnings, and binding log:

{% code title="Controller" %}

```php
$info = Quartermaster::posts()
    ->postType('event')
    ->orderBy('meta_value')  // ← no meta_key set!
    ->explain();

// $info['args']     → final WP_Query args
// $info['applied']  → [['name' => 'postType', 'params' => ['event']], ...]
// $info['warnings'] → ['Using orderby=meta_value without meta_key will produce unreliable ordering.']
```

{% endcode %}

When `bindQueryVars()` has been used, `explain()` also includes a `bindings` array showing each binding attempt — whether it was applied, skipped (and why), with values safely redacted.
{% endtab %}
{% endtabs %}

{% hint style="success" %}
`explain()` is perfect for code reviews, debugging, and making sure your queries do exactly what you intend. Think of it as the ship's manifest — every item accounted for. 🧭
{% endhint %}

### Smart warnings

Quartermaster automatically detects common gotchas:

| Warning             | Trigger                                         |
| ------------------- | ----------------------------------------------- |
| Unreliable ordering | `orderby=meta_value` without `meta_key` set     |
| Pagination ignored  | `posts_per_page=-1` with `paged` set            |
| Hidden empty terms  | `hide_empty` not explicitly set on term queries |

***

## 📖 Method Reference

<details>

<summary><strong>🚢 Bootstrap</strong></summary>

| Method                               | Description                       |
| ------------------------------------ | --------------------------------- |
| `Quartermaster::posts($postType?)`   | Start a new post query builder    |
| `Quartermaster::terms($taxonomy?)`   | Start a new term query builder    |
| `Quartermaster::prepare($postType?)` | Compatibility alias for `posts()` |

</details>

<details>

<summary><strong>📌 Post Constraints</strong></summary>

| Method                    | Sets              |
| ------------------------- | ----------------- |
| `postType(string\|array)` | `post_type`       |
| `status(string)`          | `post_status`     |
| `whereId(int)`            | `p`               |
| `whereInIds(array)`       | `post__in`        |
| `excludeIds(array)`       | `post__not_in`    |
| `whereParent(int)`        | `post_parent`     |
| `whereParentIn(array)`    | `post_parent__in` |
| `whereAuthor(int)`        | `author`          |
| `whereAuthorIn(array)`    | `author__in`      |
| `whereAuthorNotIn(array)` | `author__not_in`  |

</details>

<details>

<summary><strong>🔎 Meta Queries</strong></summary>

| Method                                              | Description                           |
| --------------------------------------------------- | ------------------------------------- |
| `whereMeta($key, $value, $compare?, $type?)`        | AND meta clause                       |
| `orWhereMeta($key, $value, $compare?, $type?)`      | OR meta clause                        |
| `whereMetaNot($key, $value)`                        | Exclude by value (handles NOT EXISTS) |
| `whereMetaDate($key, $operator, $value?, $format?)` | Date comparison (defaults to today)   |
| `whereMetaExists($key)`                             | Key exists check                      |
| `whereMetaNotExists($key)`                          | Key does not exist check              |
| `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. ⚓🚢

## Alternative serialized relationship fields

```php
$query = Quartermaster::posts('research-project')
    ->whereMetaLikeAny('other_arc_staff', [$post->ID])
    ->orWhereMetaLikeAny('arc_lead', [$post->ID]);
```

`orWhereMetaLikeAny()` accepts raw values and shares the existing serialized-string quoting. Empty arrays leave the query unchanged. Like `orWhereMeta()`, it forces the root meta relation to OR, including previous clauses; later `whereMeta()` calls retain that relation. For `required AND (A OR B)`, use an explicitly nested seed array. Prefer seed arrays for fixed arguments and `tapArgs()` for transformations of an existing query.

When migrating search queries, compare empty searches as well as populated ones. `relevanssi('')` leaves arguments unchanged: it does not enable Relevanssi or set `s`. Preserve explicit empty-search seed arguments if the plugin changes results or ordering for that state. `paged(paged: $page)` also sets the WordPress page size.

## Explicit empty input

```php
$publications = Quartermaster::posts('publication')
    ->relevanssi($search, allowEmpty: true)
    ->paged(paged: $page);

$staff = Quartermaster::posts('staff-member')
    ->all()
    ->whereTax('research-team', $teamIds, 'term_id', allowEmpty: true)
    ->excludeIds([$post->ID]);
```

Use the optional `allowEmpty` flag when emptiness is meaningful. Relevanssi sets an explicit sanitized empty `s` and enables its flag, replacing any prior search; null leaves the search unchanged. Both `Binder::relevanssi()` and `Bind::relevanssi()` support the option and skip missing/null query-var values unless an explicit default is supplied.

For `whereTax()` and `orWhereTax()`, the option retains an empty native taxonomy clause. Empty `IN` returns no posts; empty `NOT IN` excludes nothing. This removes the need for raw arrays for required empty relationships. Optional filters retain their existing skip-empty behaviour when the flag is omitted. No plugin is installed or index managed by these helpers.

## Fluent term metadata ordering

```php
$themes = Quartermaster::terms('research-theme')
    ->hideEmpty(false)
    ->orderByMetaNumeric('sort_order')
    ->timber();
```

Terms now support `orderByMeta($key, $order = 'ASC', $metaType = 'CHAR')` and `orderByMetaNumeric($key, $order = 'ASC')`, matching the post-builder vocabulary. These replace raw `meta_key`/`orderby` seed arguments. Other constraints remain intact, and the normal WordPress-backed terminal preserves final term filters. WordPress excludes terms without the selected metadata key; verify membership when replacing an in-memory sort. Invalid directions use the existing ASC fallback and warning.

### Search binding defaults and input handling

```php
$query = Quartermaster::posts('research-project')
    ->bindQueryVars(function (Binder $bindings): void {
        $bindings->relevanssi('project-search', allowEmpty: true, default: '');
    });
```

`Bind::search()` / `Binder::search()` accept `?string $default = null`; `Bind::relevanssi()` / `Binder::relevanssi()` accept it after `allowEmpty`. A default applies only to missing/null values. An explicit empty string never uses the default. Without a default, missing/null values leave the query unchanged. Use `allowEmpty: true, default: ''` to enable Relevanssi even without a search term. This deliberately opts into an empty search; it does not install or index Relevanssi.

Scalar inputs are converted to strings; arrays, objects and resources are skipped without applying the default or replacing an existing search. Sanitization belongs to the search builder: `sanitize_text_field()` in WordPress, trimming otherwise. Do not duplicate it around bindings. Bindings do not execute queries or escape HTML output; keep contextual escaping in templates.

Never automatically URL-decode query variables. PHP already decodes GET values; decoding again corrupts literal plus signs such as `C++`. WordPress sanitization strips percent-encoded characters; Quartermaster retains that established behaviour. See [PHP's urldecode guidance](https://www.php.net/manual/en/function.urldecode.php) and [WordPress sanitization](https://developer.wordpress.org/reference/functions/sanitize_text_field/). Use custom preparation only for a documented source encoding, before sanitization.
