Skip to content
  1. Extras
  2. MiniShop3
  3. Development
  4. API Router

API Router ​

MiniShop3 uses FastRoute for API request routing. The component provides two separate APIs with different authorization mechanisms.

Architecture ​

Two API types ​

ParameterManager APIWeb API
Prefix/api/mgr/*/api/v1/*
PurposeMODX managerStore frontend
Entry pointconnector.phpassets/.../api.php
AuthorizationMODX session + HTTP_MODAUTHMS3TOKEN tokens
MiddlewareAuthMiddleware, PermissionMiddlewareTokenMiddleware, CorsMiddleware, RateLimitMiddleware
Routes fileconfig/routes/manager.phpconfig/routes/web.php

File structure ​

core/components/minishop3/
├── config/
│   ├── routes/
│   │   ├── manager.php                    # Manager API system routes
│   │   └── web.php                        # Web API system routes
│   ├── ms3.routes.d/
│   │   ├── manager/example-addon.php.dist # Addon example (Manager)
│   │   └── web/example-addon.php.dist     # Addon example (Web)
│   └── routes_manager.custom.example.php  # Custom routes example

core/config/
├── ms3_routes_manager.custom.php          # Custom Manager routes
├── ms3_routes_web.custom.php              # Custom Web routes
└── ms3.routes.d/                          # Modular addon routes
    ├── manager/                           # Manager API fragments
    └── web/                               # Web API fragments

Basic usage ​

Defining routes ​

php
use MiniShop3\Router\Response;

// Simple GET route
$router->get('/api/mgr/my-endpoint', function($params) use ($modx) {
    return Response::success(['data' => 'value']);
});

// POST route
$router->post('/api/mgr/items', function($params) use ($modx) {
    $data = json_decode(file_get_contents('php://input'), true);
    return Response::success(['created' => true]);
});

// PUT route
$router->put('/api/mgr/items/{id}', function($params) use ($modx) {
    $id = $params['id'];
    return Response::success(['updated' => $id]);
});

// DELETE route
$router->delete('/api/mgr/items/{id}', function($params) use ($modx) {
    return Response::success(['deleted' => true]);
});

URL parameters ​

php
// Single parameter
$router->get('/api/mgr/products/{id}', function($params) use ($modx) {
    $id = $params['id'];  // Value from URL
    return Response::success(['product_id' => $id]);
});

// Multiple parameters
$router->get('/api/mgr/orders/{order_id}/products/{product_id}', function($params) use ($modx) {
    $orderId = $params['order_id'];
    $productId = $params['product_id'];
    return Response::success([
        'order_id' => $orderId,
        'product_id' => $productId
    ]);
});

Route groups ​

php
use MiniShop3\Router\Middleware\AuthMiddleware;
use MiniShop3\Router\Middleware\PermissionMiddleware;

$router->group('/api/mgr/catalog', function($router) use ($modx) {

    // GET /api/mgr/catalog/products
    $router->get('/products', function($params) use ($modx) {
        return Response::success(['products' => []]);
    });

    // GET /api/mgr/catalog/categories
    $router->get('/categories', function($params) use ($modx) {
        return Response::success(['categories' => []]);
    });

    // POST /api/mgr/catalog/products
    $router->post('/products', function($params) use ($modx) {
        return Response::success(['created' => true]);
    });

}, [
    // Middleware for the whole group
    new AuthMiddleware($modx, 'mgr'),
    new PermissionMiddleware($modx, 'msproduct_save')
]);

Controllers ​

For complex logic use controllers:

php
$router->group('/api/mgr/orders', function($router) use ($modx) {

    $router->get('', function($params) use ($modx) {
        $controller = new \MiniShop3\Controllers\Api\Manager\OrdersController($modx);
        return $controller->getList($params);
    });

    $router->get('/{id}', function($params) use ($modx) {
        $controller = new \MiniShop3\Controllers\Api\Manager\OrdersController($modx);
        return $controller->get($params);
    });

    $router->put('/{id}', function($params) use ($modx) {
        $body = json_decode(file_get_contents('php://input'), true) ?: [];
        $allParams = array_merge($params, $body);

        $controller = new \MiniShop3\Controllers\Api\Manager\OrdersController($modx);
        return $controller->update($allParams);
    });

});

Response ​

All API endpoints return JSON via MiniShop3\Router\Response:

php
use MiniShop3\Router\Response;

// Success response
Response::success($data, $message, $statusCode);

// Error response
Response::error($message, $statusCode, $errors);

Response format ​

Success:

json
{
  "success": true,
  "message": "Operation completed",
  "data": {
    "id": 123,
    "name": "Product"
  }
}

Error:

json
{
  "success": false,
  "message": "Validation failed",
  "errors": {
    "name": "Field is required"
  }
}

HTTP status codes ​

CodeDescriptionUsage
200OKSuccessful request
400Bad RequestValidation error
401UnauthorizedNot authenticated
403ForbiddenNo permission
404Not FoundRoute or resource not found
405Method Not AllowedMethod not allowed
429Too Many RequestsRate limit exceeded
500Internal Server ErrorServer error

Middleware ​

Middleware runs in sequence before the route handler. If middleware returns a Response, execution stops.

Interface ​

php
namespace MiniShop3\Router\Middleware;

interface MiddlewareInterface
{
    /**
     * @param array $params URL parameters
     * @return Response|null Response to stop, null to continue
     */
    public function handle(array $params);
}

Built-in middleware ​

AuthMiddleware ​

Checks MODX user authentication. For mgr context also validates HTTP_MODAUTH token.

php
use MiniShop3\Router\Middleware\AuthMiddleware;

// Auth check in manager
$router->get('/api/mgr/secure', function($params) use ($modx) {
    return Response::success(['user' => $modx->user->get('username')]);
}, [
    new AuthMiddleware($modx, 'mgr')  // 'mgr' or 'web'
]);

Checks:

  • Authenticated user $modx->user
  • For mgr: valid HTTP_MODAUTH token

PermissionMiddleware ​

Checks permissions via $modx->hasPermission().

php
use MiniShop3\Router\Middleware\PermissionMiddleware;

$router->post('/api/mgr/products', function($params) use ($modx) {
    // Create product
}, [
    new PermissionMiddleware($modx, 'msproduct_save')
]);

Main MiniShop3 permissions:

  • msproduct_save — create/edit products
  • mssetting_save — manage settings (deliveries, payments, vendors, notifications)
  • view_document — read categories and category product grids
  • msorder_list — order and customer lists, customer addresses (GET)
  • msorder_view — view customer card
  • msorder_save — edit orders and customers, order line mutations
  • msorder_remove — delete orders, customers, bulk delete

TokenMiddleware ​

Validates and auto-mints the customer token for Web API when needed.

Resolve order: Authorization: Bearer → legacy MS3TOKEN header → httpOnly cookie ms3_token → $_REQUEST → session. Query token / ms3_token is stripped and not accepted.

php
use MiniShop3\Middleware\TokenMiddleware;

$tokenMiddleware = new TokenMiddleware($modx);

$router->group('/api/v1/cart', function($router) use ($modx) {
    // Cart routes
}, [$tokenMiddleware]);

On routes with middleware, without a valid token the server auto-mints a guest token (except paths in middleware publicRoutes, e.g. logout). Catalog, health, and token/get in web.php do not use the middleware.

Details: Web API authorization.

CorsMiddleware ​

Sets CORS headers for cross-origin requests.

php
use MiniShop3\Middleware\CorsMiddleware;

$corsMiddleware = new CorsMiddleware([
    'allowed_origins' => ['https://shop.example.com', 'https://admin.example.com'],
    'allowed_methods' => ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
    'allowed_headers' => ['Content-Type', 'Authorization', 'MS3TOKEN'],
    'allow_credentials' => true,
    'max_age' => 86400  // Preflight cache (24 hours)
]);

System setting: ms3_cors_allowed_origins. Empty = same-origin; * = any origin without credentials; a domain list is required for cookies from another origin. See CORS.

RateLimitMiddleware ​

Limits request rate to prevent abuse.

php
use MiniShop3\Middleware\RateLimitMiddleware;

$rateLimitMiddleware = new RateLimitMiddleware(
    60,   // Max 60 requests
    60    // Per 60 seconds
);

System settings:

  • ms3_rate_limit_max_attempts — max requests (default: 60)
  • ms3_rate_limit_decay_seconds — window in seconds (default: 60)

Response headers:

X-RateLimit-Limit: 60
X-RateLimit-Remaining: 45
X-RateLimit-Reset: 1703001234

Custom middleware ​

php
<?php
namespace MyComponent\Middleware;

use MiniShop3\Router\Middleware\MiddlewareInterface;
use MiniShop3\Router\Response;
use MODX\Revolution\modX;

class LoggingMiddleware implements MiddlewareInterface
{
    private modX $modx;

    public function __construct(modX $modx)
    {
        $this->modx = $modx;
    }

    public function handle(array $params)
    {
        // Log request
        $this->modx->log(
            modX::LOG_LEVEL_INFO,
            sprintf(
                '[API] %s %s from %s',
                $_SERVER['REQUEST_METHOD'],
                $_SERVER['REQUEST_URI'],
                $_SERVER['REMOTE_ADDR']
            )
        );

        // null = continue
        return null;
    }
}

Usage:

php
use MyComponent\Middleware\LoggingMiddleware;

$router->get('/api/mgr/logged-endpoint', function($params) use ($modx) {
    return Response::success(['logged' => true]);
}, [
    new LoggingMiddleware($modx),
    new AuthMiddleware($modx, 'mgr')
]);

Middleware order ​

Middleware runs in the order they are listed:

php
$router->get('/api/mgr/endpoint', $handler, [
    new CorsMiddleware(),        // 1. CORS first
    new RateLimitMiddleware(),   // 2. Rate limit
    new AuthMiddleware($modx),   // 3. Auth
    new PermissionMiddleware(),  // 4. Permissions
]);

If middleware returns a Response, the rest of the middleware and the handler are not run.

Route map ​

Manager API (/api/mgr/*) ​

General ​

MethodRouteDescription
GET/healthAPI health check
GET/user/infoCurrent user info

Config (/config) ​

MethodRouteDescription
GET/page-fields/{page_key}Get page fields (no mssetting_save)
GET/page-fields/{page_key}/allAll page fields
PUT/page-fields/{page_key}Update fields (body: { "fields": [...] }, needs mssetting_save)
GET/sections/{page_key}Get sections
PUT/sections/{page_key}Update sections (body: { "sections": [...] })
DELETE/sections/{page_key}/{section_key}Delete section by key

Grid config (/grid-config) ​

CRUD for admin grid column configuration. Read: view_document, write: mssetting_save.

MethodRouteDescription
GET/{grid_key}Get grid configuration
PUT/{grid_key}Update grid configuration (body: fields)
POST/{grid_key}/fieldAdd column
PUT/{grid_key}/field/{field_name}Update column
DELETE/{grid_key}/{field_name}Delete column

Known grid_key values in MS3 1.13: orders, order_products, customers, vendors, category-products.

GET /grid-config/{grid_key} response ​
json
{
  "columns": [
    { "name": "id", "label": "ID", "type": "model", "visible": true, ... }
  ],
  "direct_filter_keys": ["query", "status_id", "delivery_id", ...],
  "editor_references": [
    { "key": "vendors", "path": "/api/mgr/references/vendors" }
  ]
}
FieldTypeDescription
columnsarrayGrid columns with configuration (type, visibility, filtering, editor)
direct_filter_keysstring[]Filter keys the controller expects as direct request parameters (no filter_ prefix). Others must be sent with the prefix. Source of truth is the backend; the frontend reads the array from here. Added in MiniShop3 1.12.0 — removes duplication between frontend and controllers.
editor_referencesarray<{key,path}>Only for grid_key=category-products. Whitelist of allowed reference keys for inline-edit combo editor. Each entry is a key/path pair to a reference API endpoint. Used by the column settings UI for select dropdown. Added in MiniShop3 1.12.0.
Filter contract (direct_filter_keys) ​

Frontend code decides how to serialize a filter value:

js
// Pseudocode: composable useGridFilterParams
function addFilterParam(params, key, value) {
    if (directFilterKeys.has(key)) {
        params[key] = value         // direct: ?status_id=2
    } else {
        params['filter_' + key] = value   // prefixed: ?filter_customer_name=...
    }
}

When adding a new direct filter on the backend, always add its key to the DIRECT_FILTER_KEYS constant in the corresponding controller. Otherwise the frontend sends it with a prefix and filtering will not work.

Orders (/orders) ​

Read and write are split into two middleware groups.

Read — permission msorder_list:

MethodRouteDescription
GET(root)List orders
GET/filtersFilter config
GET/statsAggregates for dashboard and filters
GET/{id}Get order
GET/{id}/productsOrder products
GET/{id}/logsChange history

Write — permission msorder_save:

MethodRouteDescription
POST(root)Create order from manager
DELETE/bulkBulk delete
POST/{id}/finalizeFinalize draft
POST/{id}/recalculate-costRecalculate cost
PUT/{id}Update order
DELETE/{id}Delete order
POST/{id}/productsAdd product
PUT/{id}/products/{product_id}Update line
DELETE/{id}/products/{product_id}Remove line

Form references (no separate PermissionMiddleware, mgr session):

MethodRouteDescription
GET/statuses-dropdownStatuses with translations
GET/deliveries-activeActive deliveries for select

Customers (/customers) ​

MethodRouteDescriptionPermission
GET(root)List customersmsorder_list
DELETE/bulkBulk deletemsorder_remove
GET/{id}Get customermsorder_view
PUT/{id}Update customermsorder_save
DELETE/{id}Delete customermsorder_remove
GET/{id}/addressesCustomer addressesmsorder_list
POST/{id}/addressesAdd addressmsorder_save
PUT/{id}/addresses/{address_id}Update addressmsorder_save
DELETE/{id}/addresses/{address_id}Delete addressmsorder_remove

Store settings ​

Deliveries (/deliveries), Payments (/payments), Vendors (/vendors), Statuses (/statuses), Links (/links) — CRUD with permission mssetting_save.

Product data (/product-data) ​

“Categories” and “Links” tabs on the product card (Vue). Permission msproduct_save / mgr session:

MethodRouteDescription
GET/{id}/categories/treeCategory tree (ms3_product_category_tree)
GET/{id}/linksLink list
POST/{id}/linksAdd link { slave, link }
DELETE/{id}/linksRemove link { link, master, slave }
GET/references/link-typesmsLink types (references group)
GET/references/productsProduct autocomplete

Notifications (/notifications) ​

MethodRouteDescriptionPermission
GET/referencesForm referencesmssetting_save
GET(root)List notificationsmssetting_save
GET/{id}Get notificationmssetting_save
POST(root)Create notificationmssetting_save
PUT/{id}Updatemssetting_save
DELETE/{id}Deletemssetting_save

Import (/import) ​

MethodRouteDescriptionPermission
GET/fieldsMapping fieldsmsproduct_save
POST/uploadUpload CSVmsproduct_save
POST/previewPreviewmsproduct_save
POST/startStart importmsproduct_save
GET/progress/{import_id}Import progressmsproduct_save

Web API (/api/v1/*) ​

Full path table: Web API → Endpoint map.

Guides: auth, catalog, cart, checkout, customer.

Customizing routes ​

Adding custom routes ​

Create core/config/ms3_routes_manager.custom.php:

php
<?php
use MiniShop3\Router\Middleware\AuthMiddleware;
use MiniShop3\Router\Middleware\PermissionMiddleware;
use MiniShop3\Router\Response;

// Simple route
$router->get('/api/mgr/my-custom-endpoint', function() use ($modx) {
    return Response::success(['custom' => true]);
}, [
    new AuthMiddleware($modx, 'mgr')
]);

// Route group
$router->group('/api/mgr/my-module', function($router) use ($modx) {

    $router->get('/dashboard', function() use ($modx) {
        return Response::success([
            'stats' => ['orders' => 100, 'revenue' => 50000]
        ]);
    });

    $router->post('/action', function($params) use ($modx) {
        $data = json_decode(file_get_contents('php://input'), true);
        // Process...
        return Response::success(['processed' => true]);
    });

}, [
    new AuthMiddleware($modx, 'mgr'),
    new PermissionMiddleware($modx, 'my_module_permission')
]);

For Web API create core/config/ms3_routes_web.custom.php.

Overriding system routes ​

Custom routes load after system routes and override them:

php
<?php
// core/config/ms3_routes_manager.custom.php

use MiniShop3\Router\Response;

// Override system route /api/mgr/health
$router->get('/api/mgr/health', function() use ($modx) {
    return Response::success([
        'status' => 'custom_ok',
        'version' => '1.0.0-custom',
        'timestamp' => time()
    ]);
});

Third-party integration ​

A third-party component can add routes via config:

php
<?php
// core/config/ms3_routes_manager.custom.php

use MiniShop3\Router\Response;
use MiniShop3\Router\Middleware\AuthMiddleware;

// Routes for msPromoCode component
$router->group('/api/mgr/promocodes', function($router) use ($modx) {

    $router->get('', function($params) use ($modx) {
        $codes = $modx->getCollection('msPromoCode');
        $result = [];
        foreach ($codes as $code) {
            $result[] = $code->toArray();
        }
        return Response::success(['codes' => $result]);
    });

    $router->post('', function($params) use ($modx) {
        $data = json_decode(file_get_contents('php://input'), true);

        $code = $modx->newObject('msPromoCode');
        $code->fromArray($data);

        if ($code->save()) {
            return Response::success(['id' => $code->get('id')]);
        }
        return Response::error('Failed to create promo code', 400);
    });

    $router->delete('/{id}', function($params) use ($modx) {
        $code = $modx->getObject('msPromoCode', $params['id']);
        if ($code && $code->remove()) {
            return Response::success(['deleted' => true]);
        }
        return Response::error('Not found', 404);
    });

}, [
    new AuthMiddleware($modx, 'mgr')
]);

Modular addon routes (ms3.routes.d) ​

ms3_routes_*.custom.php files suit manual customization but not addons — if two components write to the same file, install/uninstall conflicts occur.

Third-party components use the core/config/ms3.routes.d/ directory — each addon creates its own file, so there are no conflicts.

Analogy

The pattern mirrors ms3.services.d/ for services.

Structure ​

core/config/ms3.routes.d/
├── web/                          # Web API routes (api.php)
│   ├── 50-mydelivery.php
│   └── 50-mypayment.php
└── manager/                      # Manager API routes (connector)
    ├── 50-mydelivery.php
    └── 50-myadmin.php

Files load in alphabetical order. Use a numeric prefix to control priority (01-, 50-, 99-).

Load order ​

Web API (api.php):

  1. config/routes/web.php — system routes
  2. core/config/ms3_routes_web.custom.php — manual customizations
  3. core/config/ms3.routes.d/web/*.php — addons
  4. Dispatcher build()

Manager API (connector.php → Processors\Api\Router):

  1. config/routes/manager.php — system routes
  2. core/config/ms3_routes_manager.custom.php — manual customizations
  3. core/config/ms3.routes.d/manager/*.php — addons
  4. Dispatcher build()

Each subsequent level can override routes from the previous level by METHOD:PATTERN key.

Example: delivery addon Web API routes ​

php
<?php
// core/config/ms3.routes.d/web/50-mydelivery.php

use MiniShop3\Router\Response;
use MiniShop3\Middleware\TokenMiddleware;

$router->group('/api/v1/delivery/mydelivery', function ($router) use ($modx) {

    $router->post('/calculate', function (array $vars, \MODX\Revolution\modX $modx) {
        $input = json_decode(file_get_contents('php://input'), true) ?: [];
        $cost = MyDelivery::calculate($input['address'] ?? '', $input['weight'] ?? 0);
        return Response::success(['cost' => $cost]);
    });

    $router->get('/points', function (array $vars, \MODX\Revolution\modX $modx) {
        $city = $vars['city'] ?? '';
        $points = MyDelivery::getPickupPoints($city);
        return Response::success(['points' => $points]);
    });

}, [new TokenMiddleware($modx)]);

Example: addon Manager API routes ​

php
<?php
// core/config/ms3.routes.d/manager/50-mydelivery.php

use MiniShop3\Router\Middleware\AuthMiddleware;
use MiniShop3\Router\Response;

$router->group('/api/mgr/mydelivery', function ($router) use ($modx) {

    $router->get('/settings', function (array $vars, \MODX\Revolution\modX $modx) {
        return Response::success([
            'api_key' => $modx->getOption('mydelivery_api_key'),
            'enabled' => (bool) $modx->getOption('mydelivery_enabled'),
        ]);
    });

    $router->put('/settings', function (array $vars, \MODX\Revolution\modX $modx) {
        $data = json_decode(file_get_contents('php://input'), true) ?: [];
        // Save settings...
        return Response::success(['saved' => true]);
    });

}, [new AuthMiddleware($modx, 'mgr')]);

Install and uninstall ​

In the addon resolver:

php
<?php
// resolver on install
$routesDir = MODX_CORE_PATH . 'config/ms3.routes.d/';

// Copy route files
copy(
    $source . 'routes/web.php',
    $routesDir . 'web/50-mydelivery.php'
);
copy(
    $source . 'routes/manager.php',
    $routesDir . 'manager/50-mydelivery.php'
);
php
<?php
// resolver on uninstall
@unlink(MODX_CORE_PATH . 'config/ms3.routes.d/web/50-mydelivery.php');
@unlink(MODX_CORE_PATH . 'config/ms3.routes.d/manager/50-mydelivery.php');

Each addon removes only its own file — other addons are unaffected.

Error handling ​

If a route file has a syntax error or throws an exception, it is logged and skipped — other files load normally:

[MiniShop3 Router] Failed to load routes file /path/to/50-broken.php: syntax error...

Custom file vs ms3.routes.d ​

ms3_routes_*.custom.phpms3.routes.d/
ForSite developerAddon authors
FilesOne per API typeOne file per addon
ConflictsPossible with multiple addonsNone
Install/uninstallManual editing requiredAtomic: create/delete file
Created byOn ms3 install (example)Addon via resolver

System settings ​

SettingDefaultDescription
ms3_cors_allowed_origins["*"]CORS allowed origins
ms3_rate_limit_max_attempts60Request limit
ms3_rate_limit_decay_seconds60Limit window (seconds)

Debugging ​

Request logging ​

php
$router->get('/api/mgr/debug', function($params) use ($modx) {
    $modx->log(
        \MODX\Revolution\modX::LOG_LEVEL_INFO,
        '[API Debug] ' . json_encode([
            'method' => $_SERVER['REQUEST_METHOD'],
            'uri' => $_SERVER['REQUEST_URI'],
            'params' => $params,
            'body' => file_get_contents('php://input')
        ])
    );

    return Response::success(['debug' => true]);
});

Common errors ​

401 Unauthorized:

  • Not logged in to MODX
  • Missing or invalid HTTP_MODAUTH token
  • For Web API: missing or expired MS3TOKEN

403 Forbidden:

  • No permission (check user ACL)

404 Not Found:

  • Route not registered
  • URL typo

405 Method Not Allowed:

  • Wrong HTTP method (e.g. GET instead of POST)

429 Too Many Requests:

  • Rate limit exceeded; wait Retry-After seconds