For the complete documentation index, see llms.txt. This page is also available as Markdown.

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

📦 Install

Terminal
composer require pressgang-wp/quartermaster

Requirements: PHP 8.3+


✨ Why Fluent?

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

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

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.


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

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.

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.

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.

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.

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.

3. A filterable archive

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

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.

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.


🧠 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


🚀 Posts — Quick Start

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

Build an args array without executing the query:

Execute the query and get posts in one step:

When you need the full WP_Query object (pagination metadata, found rows, loop state):

Get Timber PostQuery objects directly — ideal for PressGang controllers:

Timber is optional and runtime-guarded. If Timber is unavailable, Quartermaster throws a clear RuntimeException rather than hard-coupling Timber into core.

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:

applyTo() is a terminal — it does not return the builder. Clause arrays from multiple hooks compose safely because clauses are merged, not overwritten.


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

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:

Combine when() and applyTo() for conditional hook logic:

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.


📌 Post Constraints

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

All ID methods filter non-integer values automatically and skip silently if the resulting list is empty. No invalid args, no broken queries.


🔎 Meta Queries

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

This sets relation => OR on the root meta query — matching posts where either condition is true.

When no value is provided, whereMetaDate() uses today's date via WordPress's timezone-aware wp_date(). The default format is Ymd (ACF convention).

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.

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.


🏷️ Taxonomy Queries

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:

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


📄 Pagination and Performance

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.


↕️ Ordering

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().



🏷️ Terms — Quick Start

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

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.


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

Nothing reads query vars unless you explicitly call bindQueryVars(). No smuggling, no hidden defaults.

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

If no taxonomy is provided, Binder assumes the taxonomy name matches the query var key.

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)

Skips if empty; sanitised


🗓️ Common Pattern: Meta Date vs Today

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

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.

Runs a closure when the condition is true:

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

Inverse of when()unless($x) is when(!$x):

Always runs a closure, for builder-level logic without breaking the chain:

All three are recorded in explain() for debuggability.


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

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:

The callback receives the current args array and must return the full replacement array. tapArgs() is recorded in explain() for debuggability.


🔍 Debugging and Introspection

Inspect the generated WordPress args array at any point in the chain:

Inspect args plus the full call history, warnings, and binding log:

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.

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

🚢 Bootstrap
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()

📌 Post Constraints
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

🔎 Meta Queries
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

🏷️ Taxonomy Queries
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)).

📅 Date Queries
Method
Description

whereDate(array)

Raw WordPress date_query clause

whereDateAfter(string|array, $inclusive?)

Published after date

whereDateBefore(string|array, $inclusive?)

Published before date

↕️ Ordering
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

🔍 Search
Method
Description

search(string|null)

Set search term (s); sanitised, null/empty ignored

relevanssi(string|null)

Search with Relevanssi (s + relevanssi = true); requires Relevanssi

📄 Pagination and Performance
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

🔀 Conditionals and Hooks
Method
Description

when($condition, $then, $else?)

Conditional closure

unless($condition, $then, $else?)

Inverse conditional

tap($callback)

Always-run closure

tapArgs($callback)

Raw args manipulation

🔌 Macros
Method
Description

macro($name, $callable)

Register a named macro

hasMacro($name)

Check if macro is registered

flushMacros()

Remove all macros (for tests)

🔗 Query-Var Binding
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)

🔍 Introspection
Method
Returns

toArgs()

array<string, mixed> — raw WP_Query args

explain()

array{args, applied, warnings, bindings?}

🏁 Terminals
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)

🏷️ Terms Builder
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[]


Smooth seas and predictable queries. Happy sailing. ⚓🚢

Last updated