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.

WordPress stores the selected template in the _wp_page_template meta key, so wherePageTemplate() is a shorthand for whereMeta('_wp_page_template', $template). Pass the stored value: the template path relative to the theme, or the bare slug for file-less templates. Pages with no template selected store default.

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.


🏷️ 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.

First match

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

Like toArray(), first() returns a Timber\Post when Timber is available and a WP_Post otherwise.


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

With the term-filter fix (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.


🔗 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

wherePageTemplate($template)

Match pages by _wp_page_template

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

first()

Timber\Post|WP_Post|null — first match, builder unchanged

wpQuery()

WP_Query — full query object

timber()

Timber\PostQuery — Timber post collection

applyTo($query)

void — modify existing WP_Query in place (for pre_get_posts)

🏷️ 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. ⚓🚢

Alternative serialized relationship fields

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

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

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

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 and WordPress sanitization. Use custom preparation only for a documented source encoding, before sanitization.

Last updated