⚓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. 🧭
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.
📦 Install
composer require pressgang-wp/quartermasterRequirements: PHP 8.3+
✨ Why Fluent?
WP_Query arrays are powerful, but as they grow they become harder to scan, review, and refactor.
📖
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. ⚓
👀 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.
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. ⚓
🧠 Design Philosophy
Quartermaster is intentionally light-touch. Steady hands on the wheel, predictable seas ahead. 🚢
🧱 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
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. 🧜♀️
🚀 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:
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:
🪝 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:
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.
📌 Post Constraints
Filter by ID, parent, author, and status — all with type-safe integer handling.
🔎 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.
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
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.
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.
↕️ Ordering
🔍 Search
relevanssi() requires the Relevanssi plugin to be active. Without it, the relevanssi arg is ignored by WordPress and the query falls back to default search.
🏷️ Terms — Quick Start
Quartermaster also provides a fluent builder for WP_Term_Query via Quartermaster::terms().
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.
Two styles are supported — both compile to the same binding map.
Available bindings
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
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.
🗓️ 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:
🔌 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.
Macros should call existing Quartermaster methods — avoid mutating internal args directly. Macro invocations are recorded in explain() as macro:<name> for debuggability.
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:
🔍 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.
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. 🧭
Smart warnings
Quartermaster automatically detects common gotchas:
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
🔗 Links
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