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.pathnotation - 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 + itemsresponse 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)
}| Element | Syntax | Required |
|---|---|---|
| alias | customers: | No (defaults to operation name) |
| operation | read, create, update, delete, count | Yes |
| selector | (template=customer, limit=10) | Yes |
| modifiers | [shuffle, slices=10] | No |
| props | { id, title, email } | No |
Syntax Components
| Component | Syntax | Description |
|---|---|---|
| Alias | alias: prop | Custom 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.field | Cross-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]| Modifier | Maps to | Description |
|---|---|---|
first | first() | Return first item |
last | last() | Return last item |
pop | pop() | Remove and return last item |
shift | shift() | Remove and return first item |
reverse | reverse() | Reverse the order |
shuffle | shuffle() | Randomize order |
sort=field | sort($field) | Sort by field (prefix - for descending) |
slice=start | slice($start) | Items from position N to end |
slices=qty | slices($qty) | First N items |
eq=index | eq($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
| Sanitizer | Syntax | Description |
|---|---|---|
entities | field<entities> | HTML entity encoding (applied by default) |
raw | field<raw> | Bypass all sanitization (use with caution) |
string | field<string> | Cast to string |
int / integer | field<int> | Cast to integer |
float | field<float=2> | Float with N decimal places |
email | field<email> | Validate and sanitize email address |
uppercase / upper | field<uppercase> | Convert to uppercase |
lowercase / lower | field<lowercase> | Convert to lowercase |
capitalize | field<capitalize> | Capitalize each word |
truncate | field<truncate=50> | Limit to N characters |
date | field<date=Y-m-d> | Format date/timestamp with PHP date format |
currency / money | field<currency> | Format as currency (default: $1,234.56) |
concat | field<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
| Setting | Default | Description |
|---|---|---|
| Protected Templates | [] | Templates that cannot be queried (blacklist) |
| Allowed Templates | [] | Only these templates can be queried (whitelist) |
| Enable Write Operations | true | Allow create, update, delete |
Query Limits
| Setting | Default | Description |
|---|---|---|
| Max Depth | 5 | Maximum nesting depth for props |
| Max Fields | 100 | Maximum total props per request |
| Default Limit | 50 | Limit added when selector has none |
| Max Limit | 1000 | Cap for any explicit limit=N |
| Max Execution Time | 5 | Seconds before timeout |
Feature Settings
| Setting | Default | Description |
|---|---|---|
| Always Apply Entities | true | Auto entity-encode all scalar strings |
| Enable Cross-references | true | Allow @alias.path references |
| Enable Query Cache | true | Cache read results |
| Query Cache Expire | 3600 | Cache TTL in seconds |
| Enable Rate Limit | true | Per-IP rate limiting |
| Max Queries Per Hour | 1000 | Rate limit ceiling |
| Enable Cache Headers | true | Send 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: 100Internal admin API:
Protected Templates: user, role, permission
Enable Write Operations: ✓
Max Depth: 6 | Default Limit: 50 | Max Limit: 2000API Reference
Query (facade)
| Method | Returns | Description |
|---|---|---|
execute(string $query, array $options = []) | array | Parse, validate, and run a query |
executeJson(string $query, bool $pretty = false) | string | Execute and return JSON |
parse(string $query) | array | Parse into AST without executing |
validate(string $query) | array | Parse and validate without executing |
handleApiRequest(?string $fallback = null) | void | HTTP endpoint helper (reads POST body, writes JSON response, exits) |
getParser() | QueryParser | Access the parser instance |
getProcessor() | QueryProcessor | Access the processor instance |
getDefaults() | array | Default configuration values |
QueryParser
| Method | Returns | Description |
|---|---|---|
parse(string $query) | array | Parse WireQL string into AST |
getWarnings() | array | Warnings from the last parse call |
QueryProcessor
| Method | Returns | Description |
|---|---|---|
execute(array $ast) | array | Execute a parsed AST |
setConfig(array $config) | void | Replace processor config |
getConfig() | array | Return 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 outputValidate 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
| Error | Cause | Fix |
|---|---|---|
Query must start with '{' | Missing opening brace | Wrap query in { ... } |
Unknown operation: 'name' | Missing or misspelled operation keyword | Use read, create, update, delete, or count |
create() requires template and parent | Incomplete selector | Add template=X, parent=/path/ to the selector |
Page not found | update() selector matched nothing | Check selector — update uses get() on first match |
Template 'X' is protected | Template in protected list | Remove from protected list or use a different template |
Limit capped to maximum allowed (N) | Warning, not error | The requested limit exceeded maxLimit config |
License
This module is released under the MIT License.
More modules by WireCodex
- Added 1 week ago by WireCodex
- Added 3 days ago by WireCodex
- Added 3 days ago by WireCodex
- Added 3 days ago by WireCodex
SimpleQuery
GraphQL-like query engine for ProcessWire pages with caching, rate limiting, and write support.0Added 3 days ago by WireCodexSimpleQueue
Background job queue with priority, delayed execution, retry, and LazyCron-based processing.0Added 3 days ago by WireCodexSimpleAsset
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.0Added 3 days ago by WireCodex
Install and use modules at your own risk. Always have a site and database backup before installing new modules.