⚓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. ⚓
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.
🧠 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:
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)
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.
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
📅 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().
🔗 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
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:
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.
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()
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.
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
🚢 Bootstrap
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
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
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
whereTax($taxonomy, $terms, $field?, $operator?)
Taxonomy query clause
Field defaults to slug. Operator defaults to IN. Multiple calls produce AND relation.
📅 Date Queries
whereDate(array)
Raw WordPress date_query clause
whereDateAfter(string|array, $inclusive?)
Published after date
whereDateBefore(string|array, $inclusive?)
Published before date
↕️ Ordering
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
📄 Pagination and Performance
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
🔀 Conditionals and Hooks
when($condition, $then, $else?)
Conditional closure
unless($condition, $then, $else?)
Inverse conditional
tap($callback)
Always-run closure
tapArgs($callback)
Raw args manipulation
🔌 Macros
macro($name, $callable)
Register a named macro
hasMacro($name)
Check if macro is registered
flushMacros()
Remove all macros (for tests)
🔗 Query-Var Binding
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
🔍 Introspection
toArgs()
array<string, mixed> — raw WP_Query args
explain()
array{args, applied, warnings, bindings?}
🏁 Terminals
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
taxonomy(string|array)
Set taxonomy
objectIds(int|array)
Scope to specific post(s)
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[]
🔗 Links
Smooth seas and predictable queries. Happy sailing. ⚓🚢
Last updated