SimpleQuery

GraphQL-like query engine for ProcessWire pages with caching, rate limiting, and write support.

Alpha — v0.1.0. This module is in early testing. The API may change before a stable release. Feedback and bug reports are welcome.

A WireQL-powered query engine for ProcessWire that makes building API endpoints effortless. Execute explicit CRUD operations, apply collection modifiers, sanitize field output, and cross-reference query results — all in a single expressive request.

Features


  • Explicit Operations: read, create, update, delete, count — clear intent, no inference
  • Collection Modifiers: Sort, filter, slice, and shuffle WireArray results with chainable modifiers
  • Multiple Operations: Execute several operations in one request with automatic sequential execution
  • Cross-references: Reference data from previous operations using @alias.path notation
  • Smart Sanitizers: Built-in formatters and sanitizers applied per-field with argument support
  • Field Aliasing: Rename output fields on-the-fly with colon syntax
  • Nested Fields: Deep traversal of page relationships and nested data
  • Sub-filtering: Apply ProcessWire selectors and modifiers to nested WireArray fields
  • Pagination Support: Automatic summary + items response when results are paginated
  • Type-aware Resolution: Handles PageArray, WireData, Pageimages, Pagefiles, SelectableOptionArray, and stdClass
  • Security First: XSS protection with automatic entity encoding by default, plus field name sanitization
  • Warning System: Non-fatal issues reported in warnings[] without halting execution
  • Zero Dependencies: Pure ProcessWire, no external libraries required

Installation


Install SimpleQuery through ProcessWire Admin → Modules → Site → SimpleQuery.

The simplequery() and query() global functions are available automatically after installation.

Quick Access


// Via the shorthand function (recommended)
$result = simplequery()->execute($query);

// Via the global query() helper
$result = query()->execute($query);

// Via the ProcessWire API variable
$result = wire()->simplequery->execute($query);

// Via SimpleWire suite facade (if SimpleWire is installed)
$result = simplewire()->query()->execute($query);

Query Language (WireQL)


Root Structure

A query is a block of named operations. Each operation has an explicit keyword, a selector, and optional modifiers and props.

{
    alias: operation(selector)[modifiers] {
        props
    },
    alias: operation(selector)
}
ElementSyntaxRequired
aliascustomers:No (defaults to operation name)
operationread, create, update, delete, countYes
selector(template=customer, limit=10)Yes
modifiers[shuffle, slices=10]No
props{ id, title, email }No

Syntax Components

ComponentSyntaxDescription
Aliasalias: propCustom output key name (colon separator)
Selector(key=value, key=value)ProcessWire selector string
Modifiers[func, func=arg, func=arg|arg]WireArray method calls
Props{ field, field, field }Fields to capture
Sanitizers<func, func=arg, func=arg|arg>Output formatters applied to scalars
References@alias.fieldCross-reference a previous operation result

Operations


READ

Fetch pages matching a selector and return their field values. Props and modifiers are optional.

{
    alias: read(selector)[modifiers] {
        props
    }
}

Simple read:

{
    customers: read(template=customer, limit=10) {
        id,
        title,
        email<email>
    }
}

Read a single page:

{
    customer: read(id=18283) {
        id,
        first_name,
        last_name,
        email<email>
    }
}

With modifiers:

{
    featured: read(template=product, limit=20)[shuffle, slices=5] {
        id,
        title,
        price<currency>
    }
}

No props — returns all scalar fields:

{
    customer: read(id=18283)
}

CREATE

Add a new page. The selector must contain template and parent. Props are the field values to set.

{
    alias: create(template=tpl, parent=/path/) {
        field = value,
        field = value
    }
}
{
    newCase: create(template=case, parent=/cases/) {
        title = 'Support request from John',
        body = 'The issue started yesterday evening.'
    }
}

Response:

{
    "success": true,
    "data": {
        "newCase": {
            "success": true,
            "id": 1042
        }
    }
}

UPDATE

Find a single page with get() and apply field assignments. Use a specific selector (e.g., id=N) — only the first matched page is updated.

{
    alias: update(selector) {
        field = value,
        field = value
    }
}
{
    updateCase: update(id=1042) {
        title = 'Support request — resolved',
        status = closed
    }
}

Response:

{
    "success": true,
    "data": {
        "updateCase": {
            "success": true,
            "id": 1042,
            "message": "Page updated successfully"
        }
    }
}

DELETE

Find all pages matching the selector and permanently delete each one. Returns a map of id → bool.

{
    alias: delete(selector)
}
{
    cleanup: delete(template=case, status=archived, modified<2024-01-01)
}

Response:

{
    "success": true,
    "data": {
        "cleanup": {
            "1038": true,
            "1039": true,
            "1041": false
        }
    }
}

COUNT

Return the number of pages matching a selector.

{
    alias: count(selector)
}
{
    openCases: count(template=case, status=open),
    totalCustomers: count(template=customer)
}

Response:

{
    "success": true,
    "data": {
        "openCases": 14,
        "totalCustomers": 382
    }
}

Multiple Operations in One Request

Operations execute sequentially in definition order. Each result is available to later operations via @alias references.

{
    customer: read(id=18283) {
        id,
        title,
        email<email>
    },
    orders: read(template=order, customer.id=@customer.id, limit=10, sort=-created) {
        id,
        total<currency>,
        status,
        date: created<date:Y-m-d>
    },
    openCount: count(template=order, customer.id=@customer.id, status=open)
}

Read Props


Simple Fields

{
    cases: read(template=case, limit=20) {
        id,
        title,
        body,
        status
    }
}

Field Aliasing

Use alias: fieldname to rename a field in the output.

{
    customers: read(template=customer, limit=10) {
        id,
        customerName: title,
        contactEmail: email<email>,
        companyName: company.title,
        joined: created<date:Y-m-d>
    }
}

Dot-notation

Access sub-properties of a related field directly.

{
    orders: read(template=order, limit=10) {
        id,
        thumbnail: image.url,
        companyName: company.title
    }
}

Nested Objects (WireData / Page fields)

{
    customer: read(id=18283) {
        id,
        title,
        company {
            name: title,
            industry,
            address {
                street,
                city,
                state<uppercase>
            }
        }
    }
}

Nested WireArray with Sub-selector and Modifiers

Props on WireArray fields support their own (selector) and [modifiers].

{
    customer: read(id=18283) {
        title,
        recentOrders: orders (status=open)[sort=-created, slices=5] {
            id,
            total<currency>,
            created<date:M d, Y>
        },
        certificates (status=active)[sort=expires] {
            title,
            number,
            expires<date:m/d/Y>
        }
    }
}

Modifiers


Modifiers are applied to WireArray results after the selector fetch. They are chainable and applied left-to-right.

[modifier, modifier=arg, modifier=arg|arg]
ModifierMaps toDescription
firstfirst()Return first item
lastlast()Return last item
poppop()Remove and return last item
shiftshift()Remove and return first item
reversereverse()Reverse the order
shuffleshuffle()Randomize order
sort=fieldsort($field)Sort by field (prefix - for descending)
slice=startslice($start)Items from position N to end
slices=qtyslices($qty)First N items
eq=indexeq($index)Item at position N

Examples:

// Shuffle and return 5 random items
[shuffle, slices=5]

// Sort descending, skip first 10
[sort=-created, slice=10]

// Get item at position 3
[eq=3]

Note: Modifiers that return a single item (first, last, pop, shift, eq) wrap the result in a one-item array so the response structure stays consistent. Subsequent modifiers after a single-item result are skipped with a warning.


Sanitizers


Sanitizers format scalar field values. They are applied inside <...> after a field name, as a comma-separated list. Multiple sanitizers are applied left-to-right.

field<sanitizer>
field<sanitizer=arg>
field<sanitizer=arg|arg>
field<sanitizer, sanitizer=arg>

Available Sanitizers

SanitizerSyntaxDescription
entitiesfield<entities>HTML entity encoding (applied by default)
rawfield<raw>Bypass all sanitization (use with caution)
stringfield<string>Cast to string
int / integerfield<int>Cast to integer
floatfield<float=2>Float with N decimal places
emailfield<email>Validate and sanitize email address
uppercase / upperfield<uppercase>Convert to uppercase
lowercase / lowerfield<lowercase>Convert to lowercase
capitalizefield<capitalize>Capitalize each word
truncatefield<truncate=50>Limit to N characters
datefield<date=Y-m-d>Format date/timestamp with PHP date format
currency / moneyfield<currency>Format as currency (default: $1,234.56)
concatfield<concat= USD>Append a string to the value

Currency arguments: <currency=€|3> — first arg is symbol, second is decimal places.

Sanitizer Examples

{
    orders: read(template=order, limit=5) {
        orderNumber<uppercase>,
        customerEmail<email>,
        total<currency>,
        totalEuro: total<currency=€|2>,
        created<date=F d, Y>,
        notes<entities, truncate=100>,
        quantity<int>,
        discount<float=2>
    }
}

Default Entity Encoding

When no sanitizer is specified, all string values are automatically encoded with entities to prevent XSS. Use <raw> to opt out.

{
    posts: read(template=post, limit=10) {
        title,           // automatically sanitized with entities
        body<raw>        // raw HTML — only when you trust the source
    }
}

Cross-references


Operations execute sequentially. Use @alias.path to inject a value from any previously resolved operation into a selector or assignment value.

{
    profile: read(id=18283) {
        id,
        title,
        company {
            id
        }
    },
    orders: read(template=order, customer.id=@profile.id, limit=20) {
        id,
        total<currency>,
        status
    },
    companyOrders: read(template=order, company.id=@profile.company.id) {
        id,
        total<currency>
    }
}

If a reference cannot be resolved, it is replaced with an empty string and a warning is added. Unresolved references never halt execution.


Response Structure


Flat Response (no pagination)

{
    "success": true,
    "warnings": [],
    "data": {
        "customers": [
            { "id": 1, "title": "Jane Doe", "email": "jane@example.com" },
            { "id": 2, "title": "John Smith", "email": "john@example.com" }
        ]
    }
}

read() always returns an array of items, even when the result is a single page. This keeps the client-side contract predictable.

Paginated Response

When the selector includes limit=N and the total result exceeds that limit, the response wraps items in a summary + items structure.

{
    "success": true,
    "data": {
        "customers": {
            "summary": {
                "count": 50,
                "total": 247,
                "start": 0,
                "limit": 50,
                "selector": "template=customer, limit=50",
                "pagination": true,
                "previous": false,
                "next": true,
                "pager_count": "Page 1 of 5",
                "pager_items": "Items 1–50 of 247"
            },
            "items": [
                { "id": 1, "title": "Jane Doe" },
                ...
            ]
        }
    }
}

Pagination also applies to nested WireArray props when they have a limit and are paginated.

Response with Warnings

Individual warnings do not stop execution. They are collected and returned alongside the data.

{
    "success": true,
    "warnings": [
        {
            "field": "price",
            "message": "Selector/modifiers ignored on scalar value"
        }
    ],
    "data": {
        "products": [...]
    }
}

Error Response

Parse errors or validation failures return success: false.

{
    "success": false,
    "error": {
        "message": "Query must start with '{'",
        "code": "QUERY_ERROR"
    }
}
{
    "success": false,
    "error": {
        "message": "Template 'user' is protected and cannot be queried",
        "code": "VALIDATION_ERROR",
        "all": ["Template 'user' is protected and cannot be queried"]
    }
}

Special Field Types


Pageimages

Returns an array of image maps. Pass children props to filter returned keys.

{
    product: read(id=55) {
        title,
        images {
            url,
            width,
            height,
            description
        }
    }
}

Available keys: url, width, height, description, alt, basename.

Pagefiles

Returns an array of file maps. Same child-filtering as images.

{
    document: read(id=88) {
        title,
        attachments {
            url,
            basename,
            filesize,
            ext
        }
    }
}

Available keys: url, basename, description, filesize, ext.

SelectableOptionArray

Behavior adapts to single vs. multi-select.

Single selection → flat map (direct key access):

{
    case: read(id=42) {
        status,            // → { "value": "open", "title": "Open" }
        priority { value } // → { "value": "high" }
    }
}

Multiple selections → array of maps:

{
    product: read(id=55) {
        tags // → [{ "value": "sale", "title": "On Sale" }, { "value": "new", "title": "New" }]
    }
}

Default keys returned: value and title. Request id explicitly if needed: field { id, value, title }.

User fields

Without explicit children props, returns id, name, email. Add children to select specific props.

{
    case: read(id=42) {
        title,
        assignee {
            id,
            name,
            email
        }
    }
}

API Endpoint Template


<?php
// /site/templates/api.php
namespace ProcessWire;

// Handle API requests — reads POST body / query param automatically
simplequery()->handleApiRequest();

// Or with manual handling:
$body = file_get_contents('php://input');
$data = json_decode($body, true);
$query = $data['query'] ?? wire('input')->post('query');

if (empty($query)) {
    header('Content-Type: application/json');
    echo json_encode(['success' => false, 'error' => ['message' => 'No query provided']]);
    exit;
}

$result = simplequery()->execute($query);
header('Content-Type: application/json');
echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);

Request Structure


HTTP Method

Always use POST. GET has URL length limitations that break complex queries, and POST is standard for APIs that may write data.

Content Type

Send Content-Type: application/json with a JSON body.

{
    "query": "{ customers: read(template=customer, limit=10) { id, title, email } }"
}

PHP Examples

$customerId = (int) $input->get('id');

$query = "
{
    customer: read(id=$customerId) {
        id,
        fullName: title,
        email<email>,
        company {
            name: title
        }
    },
    orders: read(template=order, customer.id=@customer.id, limit=10, sort=-created) {
        id,
        total<currency>,
        status,
        date: created<date:M d, Y>
    }
}";

$response = client('https://domain.com/api')
    ->post('/', ['query' => $query]);

$result = $response->json();

if ($result['success']) {
    $customer = $result['data']['customer'][0];
    $orders   = $result['data']['orders'];
    // or, if orders are paginated:
    // $summary = $result['data']['orders']['summary'];
    // $orders  = $result['data']['orders']['items'];
}

JavaScript — Fetch

const query = `
{
    products: read(template=product, limit=20) {
        id,
        title,
        price<currency>,
        thumbnail: image.url
    }
}
`;

fetch('https://domain.com/api', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ query })
})
.then(r => r.json())
.then(result => {
    if (result.success) {
        const products = result.data.products;
        // Check for pagination
        const items = products.items ?? products;
    }
});

JavaScript — Axios

const { data: result } = await axios.post('https://domain.com/api', { query });

if (result.success) {
    result.data.products.forEach(p => console.log(p.title, p.price));
}

cURL

curl -X POST https://domain.com/api \
  -H "Content-Type: application/json" \
  -d '{"query": "{ total: count(template=customer) }"}'

Security


Default XSS Protection

All scalar field values are automatically sanitized with entities unless you specify a sanitizer. The first explicit sanitizer you provide replaces the default — add entities to the chain if you still want it alongside other sanitizers.

{
    comments: read(template=comment, limit=20) {
        author,             // → entities applied automatically
        text,               // → entities applied automatically
        body<raw>,          // → no sanitization — only use with trusted content
        excerpt<truncate=200> // → truncate only, entities not applied
    }
}

Field Name Sanitization

All field names in prop lists are sanitized with $sanitizer->fieldName() before being used to access page properties. This strips any characters that are invalid in ProcessWire field names.

Template Access Control

Configure allowed and protected templates in the module settings, or validate in code:

$validation = simplequery()->validate($query);

if (!$validation['valid']) {
    http_response_code(400);
    echo json_encode(['success' => false, 'errors' => $validation['errors']]);
    exit;
}

Module Configuration


Navigate to Modules → Site → SimpleQuery → Configure to set global defaults.

Security Settings

SettingDefaultDescription
Protected Templates[]Templates that cannot be queried (blacklist)
Allowed Templates[]Only these templates can be queried (whitelist)
Enable Write OperationstrueAllow create, update, delete

Query Limits

SettingDefaultDescription
Max Depth5Maximum nesting depth for props
Max Fields100Maximum total props per request
Default Limit50Limit added when selector has none
Max Limit1000Cap for any explicit limit=N
Max Execution Time5Seconds before timeout

Feature Settings

SettingDefaultDescription
Always Apply EntitiestrueAuto entity-encode all scalar strings
Enable Cross-referencestrueAllow @alias.path references
Enable Query CachetrueCache read results
Query Cache Expire3600Cache TTL in seconds
Enable Rate LimittruePer-IP rate limiting
Max Queries Per Hour1000Rate limit ceiling
Enable Cache HeaderstrueSend Cache-Control and ETag headers

Per-request Overrides

$result = simplequery()->execute($query, [
    'maxDepth'              => 3,
    'maxFields'             => 50,
    'enableWriteOperations' => false,
    'alwaysEntitiesTransform' => false,
]);

Configuration Profiles

Public read-only API:

Allowed Templates: product, category, article
Enable Write Operations: ✗
Max Depth: 4 | Default Limit: 20 | Max Limit: 100

Internal admin API:

Protected Templates: user, role, permission
Enable Write Operations: ✓
Max Depth: 6 | Default Limit: 50 | Max Limit: 2000

API Reference


Query (facade)

MethodReturnsDescription
execute(string $query, array $options = [])arrayParse, validate, and run a query
executeJson(string $query, bool $pretty = false)stringExecute and return JSON
parse(string $query)arrayParse into AST without executing
validate(string $query)arrayParse and validate without executing
handleApiRequest(?string $fallback = null)voidHTTP endpoint helper (reads POST body, writes JSON response, exits)
getParser()QueryParserAccess the parser instance
getProcessor()QueryProcessorAccess the processor instance
getDefaults()arrayDefault configuration values

QueryParser

MethodReturnsDescription
parse(string $query)arrayParse WireQL string into AST
getWarnings()arrayWarnings from the last parse call

QueryProcessor

MethodReturnsDescription
execute(array $ast)arrayExecute a parsed AST
setConfig(array $config)voidReplace processor config
getConfig()arrayReturn current config

Advanced Examples


Customer Portal

{
    profile: read(id=18283) {
        id,
        fullName: title,
        email<email>,
        phone,
        status,
        company {
            name: title,
            industry,
            address {
                street,
                city,
                state<uppercase>,
                zip
            }
        }
    },
    orders: read(template=order, customer.id=@profile.id, limit=20, sort=-created) {
        id,
        orderNumber<uppercase>,
        date: created<date:M d, Y>,
        total<currency>,
        status,
        items {
            product {
                name: title<truncate=50>,
                sku<uppercase>
            },
            quantity<int>,
            price<currency>
        }
    },
    openTickets: count(template=ticket, customer.id=@profile.id, status=open)
}

Product Catalog with Pagination

{
    products: read(template=product, category=electronics, limit=24, start=0, sort=title) {
        id,
        title<truncate=60>,
        slug: name,
        price<currency>,
        salePrice: sale_price<currency>,
        thumbnail: image.url,
        rating<float=1>,
        stock<int>,
        category {
            name: title
        }
    },
    totalProducts: count(template=product, category=electronics)
}

Dashboard Stats

{
    newCustomers: count(template=customer, created>=1704067200),
    openOrders: count(template=order, status=open),
    recentOrders: read(template=order, limit=10, sort=-created) {
        id,
        customer { name: title },
        total<currency>,
        status,
        date: created<date:Y-m-d>
    },
    topProducts: read(template=product, limit=5, sort=-sales) {
        title<truncate=40>,
        sales<int>,
        revenue<currency>
    }
}

Create and Immediately Read

{
    newCase: create(template=case, parent=/cases/) {
        title = 'Issue with order #1042',
        priority = high,
        status = open
    },
    allOpen: read(template=case, status=open, limit=5, sort=-created) {
        id,
        title,
        priority,
        created<date:Y-m-d>
    }
}

Best Practices


Always Use Explicit Limits

// Good — explicit limit
{ cases: read(template=case, status=open, limit=50) { id, title } }

// Risky — relies on config defaultLimit
{ cases: read(template=case, status=open) { id, title } }

Sanitize Interpolated Values

$customerId = (int) $input->get('customer_id');
$limit      = min((int) $input->get('limit', 10), 100);
$search     = $sanitizer->text($input->get('q'));

$query = "
{
    results: read(template=customer, title~=$search, limit=$limit) {
        id,
        title,
        email
    }
}";

Use Cross-references to Avoid Duplicate Queries

// Good
{
    customer: read(id=123) { id },
    orders: read(template=order, customer.id=@customer.id) { id, total }
}

// Wasteful — fetches customer twice
{
    customer: read(id=123) { id, title },
    orders: read(template=order, customer.id=123) { id, total }
}

Handle Paginated Responses on the Client

$data = $result['data']['products'];

// Check for paginated structure
if (isset($data['summary'])) {
    $summary  = $data['summary'];
    $products = $data['items'];
    echo "Showing {$summary['count']} of {$summary['total']}";
} else {
    $products = $data;
}

Log Warnings in Development

$result = simplequery()->execute($query);

if (!empty($result['warnings'])) {
    foreach ($result['warnings'] as $w) {
        wire('log')->warning("SimpleQuery [{$w['field']}]: {$w['message']}");
    }
}

Validate Before Executing in Critical Paths

$validation = simplequery()->validate($query);

if (!$validation['valid']) {
    return ['success' => false, 'errors' => $validation['errors']];
}

$result = simplequery()->execute($query);

Troubleshooting


Inspect the AST

$ast = simplequery()->parse($query);
bd($ast); // ProcessWire debug output

Validate Without Executing

$validation = simplequery()->validate($query);

foreach ($validation['errors'] as $error) {
    echo "Error: $error\n";
}
foreach ($validation['warnings'] as $w) {
    echo "Warning [{$w['field']}]: {$w['message']}\n";
}

Common Errors

ErrorCauseFix
Query must start with '{'Missing opening braceWrap query in { ... }
Unknown operation: 'name'Missing or misspelled operation keywordUse read, create, update, delete, or count
create() requires template and parentIncomplete selectorAdd template=X, parent=/path/ to the selector
Page not foundupdate() selector matched nothingCheck selector — update uses get() on first match
Template 'X' is protectedTemplate in protected listRemove from protected list or use a different template
Limit capped to maximum allowed (N)Warning, not errorThe requested limit exceeded maxLimit config

License


This module is released under the MIT License.

More modules by WireCodex

  • SimpleRouter

    URL routing with pattern matching and caching for ProcessWire.
  • SimpleClient

    Fluent cURL-based HTTP client with retry, file download, and concurrent pool support.
  • SimpleRequest

    HTTP request abstraction with input handling, validation, and content negotiation.
  • SimpleResponse

    HTTP response builder with HTMX support, redirects, and content negotiation.
  • SimpleQuery

    GraphQL-like query engine for ProcessWire pages with caching, rate limiting, and write support.
  • SimpleQueue

    Background job queue with priority, delayed execution, retry, and LazyCron-based processing.
  • SimpleAsset

    Asset management for ProcessWire. Resolves, groups, and renders CSS/JS assets from CDN sources or local paths with cache-busting, SRI, and inline threshold support.

All modules by WireCodex

Install and use modules at your own risk. Always have a site and database backup before installing new modules.