Developing RESTful APIs
Last updated Oct 20th, 2026 | Page history | Improve this page | Report an issue
Support the team building MODX with a monthly donation.
The budget raised through OpenCollective is transparent, including payouts, and any contributor can apply to be paid for their work on MODX.
Backers
Budget
$186 per month—let's make that $500!
Learn moreMODX ships a small REST server stack built around modRestService and modRestController. You point HTTP requests at a bootstrap script, map URL paths to controller classes, and get CRUD behaviour over xPDO objects with hooks for auth, validation, and response shaping.
This page covers MODX 2.x and MODX 3.x. Class locations changed in 3.x (MODX\Revolution\Rest\*). The public controller API matches across versions, the service config does not: 3.x adds sanitize, 2.x has xmlDisableEntityLoader.
Outgoing HTTP from MODX to third-party APIs is a different stack. See HTTP Client. The deprecated
modRestclient is documented under Services.
Recommended reading¶
Phil Sturgeon’s Build APIs you won’t hate is a solid primer on REST conventions before you wire controllers.
In a nutshell¶
- Create
rest/index.phpthat boots MODX, configuresmodRestService, then callsprepare()andprocess(). - Route
/rest/*to that script (Apache.htaccessinsiderest/, or an nginxlocation). - Add one controller class per endpoint under
rest/Controllers/.
1. Bootstrapping the API¶
Place the API under /rest/ (adjust paths if you use another folder).
MODX 3.x¶
<?php
use MODX\Revolution\modX;
use MODX\Revolution\Rest\modRestService;
require_once dirname(__DIR__) . '/config.core.php';
require_once MODX_CORE_PATH . 'vendor/autoload.php';
$modx = new modX();
$modx->initialize('web');
$modx->getService('error', 'error.modError', '', '');
$path = $modx->getOption(
'mypackage.core_path',
null,
$modx->getOption('core_path') . 'components/mypackage/'
) . 'model/mypackage/';
$modx->addPackage('mypackage', $path);
$rest = new modRestService($modx, [
'basePath' => __DIR__ . '/Controllers/',
'controllerClassSeparator' => '',
'controllerClassPrefix' => 'MyController',
'xmlRootNode' => 'response',
]);
$rest->prepare();
if (!$rest->checkPermissions()) {
$rest->sendUnauthorized(true);
}
$rest->process();
MODX 2.x¶
<?php
require_once dirname(dirname(__FILE__)) . '/config.core.php';
require_once MODX_CORE_PATH . 'model/modx/modx.class.php';
$modx = new modX();
$modx->initialize('web');
$modx->getService('error', 'error.modError', '', '');
$path = $modx->getOption(
'mypackage.core_path',
null,
$modx->getOption('core_path') . 'components/mypackage/'
) . 'model/mypackage/';
$modx->getService('mypackage', 'myPackage', $path);
$rest = $modx->getService('rest', 'rest.modRestService', '', [
'basePath' => dirname(__FILE__) . '/Controllers/',
'controllerClassSeparator' => '',
'controllerClassPrefix' => 'MyController',
'xmlRootNode' => 'response',
]);
$rest->prepare();
if (!$rest->checkPermissions()) {
$rest->sendUnauthorized(true);
}
$rest->process();
checkPermissions() on the service returns true by default. Override it on a custom service subclass (or gate traffic earlier) when every request must pass a site-wide check. Per-controller auth uses verifyAuthentication() (see below).
[!WARNING]
process()does not return a response — it sends the payload and ends the script (exit(0)). Nothing after the call runs, and callers must read the result from the output. SetexitOnResponse => falseto keep control.
A request with no path segment (/rest/) routes to the Index controller; the name comes from the defaultAction option.
URL rewriting¶
Put rewrite rules inside rest/ so the rest of the site keeps normal routing.
Apache (rest/.htaccess):
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ index.php?_rest=$1 [QSA,L]
nginx:
location /rest/ {
try_files $uri @modx_rest;
}
location @modx_rest {
rewrite ^/rest/(.*)$ /rest/index.php?_rest=$1&$args last;
}
Open /rest/foobar in a browser. A working bootstrap returns JSON like:
{
"success": false,
"message": "Method not allowed",
"object": [],
"code": 405
}
That 405 with the message Method not allowed means the router ran and found no matching controller. Next step: add controllers.
Useful modRestService options¶
| Option | Default | Role |
|---|---|---|
basePath |
site base_path |
Directory of controller files |
controllerClassPrefix |
modRestController |
Prefix prepended to the class loaded from the file name |
controllerClassSeparator |
'' |
Joiner between prefix and class segment |
controllerClassFilePostfix |
.php |
Extension used when scanning directories |
defaultAction |
index |
Controller for a request with no path segment |
requestParameter |
_rest |
Query param that carries the path (items, items/15) |
defaultResponseFormat |
json |
Format used when no suffix is present |
supportedFormats |
xml,json,qs |
Suffixes recognised as formats |
checkForSuffix |
true |
Whether to parse a format suffix from the path |
collectionResultsKey / collectionTotalKey |
results / total |
Keys in list responses |
propertyLimit / propertyOffset |
limit / start |
Query param names for pagination |
propertySort / propertySortDir |
sort / dir |
Query param names for sorting |
propertySearch |
search |
Query param name for text search |
defaultSuccessStatusCode / defaultFailureStatusCode |
200 / 200 |
HTTP status codes used by success() / failure() |
responseSuccessKey / responseMessageKey / responseObjectKey / responseErrorsKey |
success / message / object / errors |
Keys in a controller response body |
errorMessageSeparator |
' ' |
Separator between field errors |
trimParameters |
false |
Trim whitespace from string request parameters |
sanitize |
false |
3.x only: run parameters through modX::sanitize() |
exitOnResponse |
true |
End the script after sending the response |
xmlRootNode |
response |
Root node of XML output |
An unknown suffix is not an error — the request falls back to defaultResponseFormat. Service-level error codes (400, 401, 405, 500) always carry the keys success, message, object, and code; the response*Key options do not affect them.
2. Building API endpoints¶
Map one path segment to one resource type. HTTP verbs do the work:
GET /items: listGET /items/15: read primary key15POST /items: createPUT /items/15: updateDELETE /items/15: delete
Skip paths like /items/create. POST /items already means create.
With controllerClassPrefix => 'MyController' and empty separator, file rest/Controllers/Items.php must define class MyControllerItems. Path matching is case-insensitive: /rest/Items and /rest/items reach the same controller.
MODX 3.x controller¶
<?php
use MODX\Revolution\Rest\modRestController;
class MyControllerItems extends modRestController
{
public $classKey = 'ToDoItem';
public $defaultSortField = 'sortorder';
public $defaultSortDirection = 'ASC';
}
MODX 2.x controller¶
<?php
class MyControllerItems extends modRestController
{
public $classKey = 'ToDoItem';
public $defaultSortField = 'sortorder';
public $defaultSortDirection = 'ASC';
}
Load the package with addPackage() (or your Extra’s service) before process() so ToDoItem resolves. For a quick experiment without a custom package, set $classKey = 'modResource' (or \MODX\Revolution\modResource in 3.x) and $defaultSortField = 'id'.
GET /rest/items then returns a collection:
{
"results": [
{
"id": 1,
"sortorder": 1,
"name": "Finish documenting RESTful APIs",
"added": "2014-09-14",
"target_completion_date": "2014-10-14",
"assigned_to": ""
}
],
"total": 1
}
GET /rest/items/1, POST, PUT, and DELETE work from the same class. Use curl or an API client to send non-GET verbs.
Request routing¶
| Request | Controller entry |
|---|---|
GET /items |
get() → getList() |
GET /items/5 |
get() → read($id) |
POST /items |
post() |
PUT /items/5 |
put() |
DELETE /items/5 |
delete() |
The ID segment is copied onto $this->primaryKeyField (default id) before the method runs.
A controller without a method for the requested verb returns 405 Unsupported HTTP method. A class that cannot be instantiated (an abstract base, for instance) returns 400. If the verb method never calls success(), failure(), or collection(), the service answers with a second 405 instead of your payload.
Parameters are collected per verb: GET reads the query string only, POST merges the query string with the body, PUT uses $_POST and the body (query string ignored), DELETE merges the query string with the body.
List query parameters¶
Defaults from the service config:
| Param | Purpose |
|---|---|
limit |
Page size ($defaultLimit, default 20) |
start |
Offset |
sort |
Field name (falls back to $defaultSortField, default name) |
dir |
ASC or DESC |
search |
LIKE filter across $searchFields |
Example: /rest/items?limit=10&start=0&sort=name&dir=ASC&search=docs
3. Controller properties¶
Set these on your controller class to drive the default CRUD path:
| Property | Role |
|---|---|
$classKey |
xPDO class to load |
$classAlias |
Optional query alias |
$primaryKeyField |
Field used for read/update/delete (default id) |
$defaultSortField / $defaultSortDirection |
Default list sorting (name and ASC) |
$defaultLimit / $defaultOffset |
Default pagination (20 and 0) |
$searchFields |
Fields searched when search is present |
$postRequiredFields / $putRequiredFields / $deleteRequiredFields |
Required request fields |
$postRequiredRelatedObjects / $putRequiredRelatedObjects |
Map of field => classKey that must exist |
$postMethod / $putMethod / $deleteMethod |
Object methods called to persist (save / save / remove) |
$allowedMethods |
Verbs advertised in Access-Control-Allow-Methods on OPTIONS |
$allowedHeaders |
Headers advertised in Access-Control-Allow-Headers (default Content-Type) |
[!WARNING]
$allowedMethodsdoes not restrict requests. A verb is accepted only when the controller defines a method of that name; anything else returns405. The list feeds the CORS header and nothing else.
Do not override the controller constructor — the service instantiates the class.
4. Hooks and response shaping¶
Default get / post / put / delete already handle the happy path. Override hooks when you need filters, permissions, or a different payload.
Initialisation: initialize() runs after the auth check and before the verb method — a good place for shared controller setup.
Before write/delete: return true to continue, or false / an error string to abort:
beforePost()beforePut()beforeDelete()
The object sits on $this->object. A null return means “continue” in beforePost() and beforePut(), but not in beforeDelete(): there null aborts the request and reaches failure() with an empty message. Return true explicitly.
Set the text the client sees on a false return with setErrorMessage(); add or drop individual field errors with addFieldError() / removeFieldError().
After read/write: afterRead, afterPost, afterPut, and afterDelete receive the outgoing array by reference. Change fields there before the response leaves.
List customisation:
prepareListQueryBeforeCount(xPDOQuery $c)/prepareListQueryAfterCount(xPDOQuery $c): add joins orwhereclausesprepareListObject(xPDOObject $object): map each row (defaulttoArray())addSearchQuery(): override search behaviour
Object access:
getPrimaryKeyCriteria($id): read/update/delete criteria — the place for custom conditions and composite keyssetObjectFields(&$object, array $values): copy values onto the object
Model errors are picked up automatically: when the object implements getErrors(), they reach the response. Wording comes from the rest lexicon, which the service loads itself.
Helpers you call from overrides:
$this->getProperty($key)/setProperty()/unsetProperty()/setProperties()/getProperties()$this->setHeaders()/getHeaders()$this->setDefault($key, $value)/setDefaults(array $values)$this->checkRequiredFields(array $fields)— returnstrueor an error string$this->checkRequiredRelatedObjects(array $pairs)$this->success($message, $object, $status)$this->failure($message, $object, $status)$this->collection($list, $total, $status)$this->addFieldError($field, $message)
Example: expose only a subset of fields on list responses:
protected function prepareListObject(xPDOObject $object)
{
$row = $object->toArray();
return [
'id' => $row['id'],
'name' => $row['name'],
'sortorder' => $row['sortorder'],
];
}
Example: reject creates for one reserved name:
public $postRequiredFields = ['name'];
public function beforePost()
{
if ($this->getProperty('name') === 'blocked') {
return 'That name is not allowed';
}
return parent::beforePost();
}
5. Authentication and protection¶
Each controller has protected $protected = true by default. When protected, process() calls verifyAuthentication() before the verb method (except OPTIONS). The stock verifyAuthentication() returns true.
At that point the ID property is still unset and $this->object is still null — both arrive later. Read $this->getProperties() for payload-based permission checks.
For a public endpoint, set:
protected $protected = false;
For a real check, keep $protected = true and override:
public function verifyAuthentication()
{
$key = $this->getProperty('api_key');
return $key && $key === $this->modx->getOption('mypackage.api_key');
}
Failed auth becomes HTTP 401 with the standard error payload. Pair this with HTTPS and your own token or session scheme; modRestService ships no OAuth mechanism.
modRestService::checkPermissions() is a separate gate that runs in your bootstrap when you call it. Override that method on a custom service class for global rules.
6. Response format¶
The format comes from a path suffix or from defaultResponseFormat. Three ship out of the box:
| Suffix | Content-Type | Body |
|---|---|---|
none, .json |
application/json |
JSON |
.xml |
application/xml |
XML rooted at xmlRootNode |
.qs |
text/plain |
URL-encoded string |
Request bodies are parsed by Content-Type: application/json, application/xml, or application/x-www-form-urlencoded (the default). With image/jpeg, image/png, or image/gif the properties receive an open filehandle stream.
When the service parses inbound XML request bodies, an empty XML element becomes an empty string ('') in the resulting PHP array, not an empty array. #14305 Controllers that treat “empty” with empty() or strict === '' checks stay consistent with JSON clients sending "".
success() and failure() bodies use the keys from the service config (success, message, object, plus errors when field errors exist) and always carry code. List calls use results and total.
Default HTTP status for both success and failure is 200. Pass a third argument to success() / failure(), or change defaultSuccessStatusCode / defaultFailureStatusCode, when you need 201/404-style codes.
7. Nested controllers¶
Controllers may live in subfolders under basePath. The class name still uses the configured prefix and separator. With an empty separator, Controllers/Users/Profile.php maps to a class like MyControllerUsersProfile and path /rest/users/profile.
See also¶
- HTTP Client: outbound requests from MODX 3
- modRest: deprecated outbound client
- xPDO retrieving objects: query patterns you reuse in
prepareListQuery*
Support the team building MODX with a monthly donation.
The budget raised through OpenCollective is transparent, including payouts, and any contributor can apply to be paid for their work on MODX.
Backers
Budget
$186 per month—let's make that $500!
Learn more










