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.
- 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
Install SimpleQuery through ProcessWire Admin → Modules → Site → SimpleQuery.
The simplequery() and query() global functions are available automatically after installation.
// 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);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 |
| 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 |
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)
}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
}
}
}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"
}
}
}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
}
}
}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
}
}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)
}{
cases: read(template=case, limit=20) {
id,
title,
body,
status
}
}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>
}
}Access sub-properties of a related field directly.
{
orders: read(template=order, limit=10) {
id,
thumbnail: image.url,
companyName: company.title
}
}{
customer: read(id=18283) {
id,
title,
company {
name: title,
industry,
address {
street,
city,
state<uppercase>
}
}
}
}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 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 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>
| 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.
{
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>
}
}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
}
}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.
{
"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.
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.
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": [...]
}
}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"]
}
}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.
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.
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 }.
Without explicit children props, returns id, name, email. Add children to select specific props.
{
case: read(id=42) {
title,
assignee {
id,
name,
email
}
}
}<?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);Always use POST. GET has URL length limitations that break complex queries, and POST is standard for APIs that may write data.
Send Content-Type: application/json with a JSON body.
{
"query": "{ customers: read(template=customer, limit=10) { id, title, email } }"
}$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'];
}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;
}
});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 -X POST https://domain.com/api \
-H "Content-Type: application/json" \
-d '{"query": "{ total: count(template=customer) }"}'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
}
}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.
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;
}Navigate to Modules → Site → SimpleQuery → Configure to set global defaults.
| 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 |
| 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 |
| 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 |
$result = simplequery()->execute($query, [
'maxDepth' => 3,
'maxFields' => 50,
'enableWriteOperations' => false,
'alwaysEntitiesTransform' => false,
]);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
| 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 |
| Method | Returns | Description |
|---|---|---|
parse(string $query) |
array |
Parse WireQL string into AST |
getWarnings() |
array |
Warnings from the last parse call |
| Method | Returns | Description |
|---|---|---|
execute(array $ast) |
array |
Execute a parsed AST |
setConfig(array $config) |
void |
Replace processor config |
getConfig() |
array |
Return current config |
{
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)
}{
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)
}{
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>
}
}{
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>
}
}// 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 } }$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
}
}
";// 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 }
}$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;
}$result = simplequery()->execute($query);
if (!empty($result['warnings'])) {
foreach ($result['warnings'] as $w) {
wire('log')->warning("SimpleQuery [{$w['field']}]: {$w['message']}");
}
}$validation = simplequery()->validate($query);
if (!$validation['valid']) {
return ['success' => false, 'errors' => $validation['errors']];
}
$result = simplequery()->execute($query);$ast = simplequery()->parse($query);
bd($ast); // ProcessWire debug output$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";
}| 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 |
This module is released under the MIT License.