Skip to content

⚓ composer require · PHP 8.3+ · zero runtime dependencies · works in any theme or plugin

The fluent WordPress query builder — real WP_Query args out

Chainable, args-first methods for WP_Query and WP_Term_Query. Sprawling meta_query arrays become readable, composable steps — no ORM, no SQL layer, no hidden defaults.

Read the docs

Quartermaster does not replace WordPress queries. It makes the argument-building layer explicit, composable and easier to review.

Raw WP_Query — four things must line up silently

$events = new WP_Query([
  'post_type'      => 'event',
  'posts_per_page' => 12,
  'meta_key'       => 'start',
  'orderby'        => 'meta_value',
  'order'          => 'ASC',
  'meta_query'     => [[
    'key'     => 'start',
    'value'   => wp_date('Ymd'),
    'compare' => '>=',
    'type'    => 'DATE',
  ]],
]);

With Quartermaster ⚓

$events = Quartermaster::posts('event')
  ->paged(12)
  ->whereMetaDate('start', '>=')
  ->orderByMeta('start', 'ASC')
  ->wpQuery();

Timezone-aware dates; meta_key and orderby set together, never silently mismatched. The output is the same plain args array.

Why fluent? Not an ORM. Not a SQL builder. On purpose.

Most WordPress query builders either wrap $wpdb in a SQL layer or bolt an Eloquent-style ORM onto WordPress. Quartermaster does neither: every fluent method maps to a real WP_Query arg, so caching, plugins, and pre_get_posts all keep working.

✨ Readability

Query intent expressed step-by-step — no more scanning nested arrays to work out what a query does.

🧩 Composability

Add or remove clauses with when() / unless() without rewriting a large array.

🛡️ Safety

Methods are explicit about which WP args they set. Zero side effects by default — posts()->toArgs() is an empty array.

🔍 Transparency

Nothing runs until you say so — inspect the exact args first, so code review sees the query, not a guess.

Built for queries that grow

Start with everyday queries and grow into bindings, conditionals and hooks — the API scales with the query, so the simple case never pays for the complex one.

$events = Quartermaster::posts('event')
    ->status('publish')
    ->paged(12)
    ->orderByDesc('date')
    ->get();

01 · The basics

Everyday queries, readable at a glance

Start simple: seed the post type, chain what you need, call get(). Every method maps to the WP_Query arg you already know — nothing new to learn, just less to mistype.

Quick start
// Inspect the args before anything runs
$args = Quartermaster::posts('event')
    ->orderByMeta('start', 'ASC')
    ->toArgs(); // plain WP_Query args

// Or the full story: calls + warnings
$report = Quartermaster::posts('event')
    ->orderBy('meta_value')
    ->explain();

02 · Introspection

See exactly what you built

toArgs() returns the exact args array before anything runs; explain() adds the applied calls and warnings. Perfect for code review, debugging, and keeping junior crew out of trouble.

Introspection docs
$events = Quartermaster::posts('event')
    ->whereMeta('featured', 1)
    ->whereMetaExists('venue')
    ->whereTax('genre', ['rock', 'jazz'], 'slug')
    ->get();

03 · Meta & tax queries

meta_query without the nesting

The arrays WordPress developers dread most — meta_query and tax_query — become one call per clause. AND by default, orWhereMeta() and orWhereTax() for OR.

Meta & tax docs
$terms = Quartermaster::terms('category')
    ->hideEmpty()
    ->orderBy('name')
    ->limit(20)
    ->get();

$tags = Quartermaster::terms('post_tag')
    ->objectIds($post->ID)
    ->get();

04 · Terms too

WP_Term_Query, same fluent treatment

terms() covers the taxonomy side — hierarchy helpers like childOf(), childless() and excludeTree() included — with the same toArgs() introspection.

Terms docs
$q = Quartermaster::posts('route')->bindQueryVars([
    'paged'        => Bind::paged(),
    'difficulty'   => Bind::tax('route_difficulty'),
    'min_distance' => Bind::metaNum('distance_miles', '>='),
    'search'       => Bind::search(),
]);

05 · Query-var binding

Filter UIs without the URL-parsing slog

Declaratively map URL query vars to query clauses — pagination, taxonomy filters, numeric meta ranges, search. Nothing reads a query var unless you explicitly bind it.

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

06 · Conditional chains

Branch without breaking the chain

when(), unless() and tap() keep branching queries readable — no magic, no hidden state, and every call recorded in explain(). Project-specific sugar goes in opt-in macros.

Conditionals & macros docs
// Lean related posts — IDs only
$ids = Quartermaster::posts('post')
    ->whereTax('category', $catId, 'term_id')
    ->excludeIds([$post->ID])
    ->idsOnly()
    ->noFoundRows()
    ->limit(4)
    ->get();

// WP_Term_Query, same fluent treatment
$leafIds = Quartermaster::terms('category')
    ->childless()
    ->fields('ids')
    ->get();

07 · Query shaping

Performance flags, spelled out

idsOnly(), noFoundRows() and cache flags make WordPress's most-forgotten query optimisations first-class, readable calls. Meta queries go further than raw arrays comfortably allow — whereMetaLikeAny(), whereMetaExists(), orWhereMeta() — each mapping to real clauses you can inspect.

Method index
add_action('pre_get_posts', function (WP_Query $query) {
    if (! $query->is_main_query() || is_admin()) return;

    Quartermaster::posts('product')
        ->whereMetaExists('_price')
        ->applyTo($query);
});

08 · Hooks & Timber

Plays well with pre_get_posts

applyTo() modifies an existing query safely inside pre_get_posts — clause arrays merge with existing ones rather than overwriting, so multiple hooks compose safely. An optional timber() terminal returns Timber objects when Timber is present — runtime-guarded, never hard-coupled.

Full method index

Design philosophy

You decide what goes aboard. Nothing gets smuggled in.

Quartermaster is intentionally light-touch: no global state, no ORM, no mutation of WordPress internals and no SQL abstraction. It helps you build the same arguments WordPress already understands.

Sometimes raw WP_Query is fine. Quartermaster earns its keep when queries branch, grow, need tests, or must be safely combined across controller and hook boundaries.

Quartermaster::posts()->toArgs(); // []
Quartermaster mascot carrying WordPress query cargo

Standalone package. Part of the PressGang ecosystem.

Quartermaster works in any WordPress theme or plugin. It also fits naturally with PressGang, where controllers own context and Quartermaster owns the query-argument layer.

The Loop

Fetching and rendering entangled in template tags and global state.

Timber + Twig

Views separate from PHP, while query construction remains hand-written.

PressGang

Controllers give context-building a home and route templates by convention.

Quartermaster

Declarative, inspectable query args that keep WordPress machinery untouched.