Bolt CMS Docs
Sign in

Architecture

API Routing

How Bolt CMS maps API requests to handler files by HTTP method, with path parameters, shared bootstraps, and a uniform JSON contract.

How Requests Map to Handlers

The API uses the same file-based idea as page routing, but it is method-aware. A URL under /api/ maps to a directory inside api/, and the HTTP method selects the handler file inside it.

POST /api/chat                 →  api/chat/POST.php
GET  /api/reports/monthly      →  api/reports/monthly/GET.php

A single front controller (api/index.php) handles every request. It resolves the URL, runs any shared setup, and includes the matching handler — so individual endpoints carry no repeated boilerplate for headers, method checks, or body parsing.

Resolution Steps

For a request under /api/, the front controller works through these steps in order:

  1. Rewrite. The whole /api/ subtree is handed off to api/index.php. Because resource paths are real directories, the API's .htaccess disables Apache's directory-slash redirect and directory indexes — otherwise /api/search would be redirected to /api/search/ before routing could run.
  2. Sanitize. The path after /api/ is cleaned the same way page routing cleans URLs (query/fragment stripped, slashes collapsed). A traversal token (. / ..) or any segment beginning with _ makes the whole request a 404.
  3. Resolve. Segments are walked left to right. Each that matches a real sub-directory is descended into; the rest become path parameters.
  4. Dispatch. The handler is {resource}/{METHOD}.php (the method is uppercased; HEAD falls back to GET). A missing handler returns 405 (with an Allow header) when the directory has other method files, otherwise 404. An OPTIONS request returns 204.
  5. Bootstrap and run. Each _bootstrap.php from the api root down to the resource directory is included first, then the handler runs with the request already parsed.

If no core resource matches, the front controller tries each installed extension's api/ tree before returning a 404, running that extension's own _bootstrap.php chain. URLs never carry an extension prefix — API fallback is always core-first.

Handler Files

Inside a resource directory, add one file per HTTP method you support — GET.php, POST.php, PUT.php, PATCH.php, DELETE.php. The names are uppercase: production filesystems are case-sensitive, so POST.php is not post.php.

The front controller has already sent the JSON content type and CORS headers, and it makes the parsed request available to the handler:

Variable Contents
$method The uppercased HTTP verb (GET, POST, …).
$params Path segments that did not match a directory, in order.
$body The parsed JSON request body as an array (falls back to $_POST, else an empty array).
$_GET Query-string parameters (the query string is preserved by the rewrite).

Call api_error($code, $message) to send the standard error envelope and stop. A complete handler is small:

<?php
// api/widgets/GET.php  →  GET /api/widgets

$limit = isset($_GET['limit']) ? (int) $_GET['limit'] : 20;

if ($limit < 1) {
    api_error(400, 'limit must be positive');
}

echo json_encode([
    'success' => true,
    'widgets' => [/* ... */],
]);

Path Parameters

Segments that do not match a directory become string entries in $params, in order. Resolution is greedy, and it continues past a parameter when a later segment matches a directory — so an id can sit in the middle of a path and routing still finds the action that follows it.

Request Handler $params
GET /api/widgets/42 widgets/GET.php ['42']
POST /api/user/1234/logout user/logout/POST.php ['1234']
GET /api/user/1/settings/2 user/settings/GET.php ['1', '2']

For /api/user/1234/logout, the router descends into user/, treats 1234 as a parameter (there is no user/1234/ directory), then resumes into user/logout/. A real sub-directory always wins over a parameter, so do not name a resource directory anything a real id could equal.

Shared Setup with _bootstrap.php

Before the handler runs, the front controller includes every _bootstrap.php from the api root down to the resource directory, top-down. This is the file-based place for shared setup — a database connection, configuration, or an authentication check — scoped to wherever the file lives.

api/_bootstrap.php           runs for every endpoint
api/reports/_bootstrap.php   runs for every /api/reports/** endpoint

For example, api/reports/_bootstrap.php loads the flat-file store once for all report endpoints, so the individual handlers do not repeat it:

<?php
require_once __DIR__ . '/../../includes/BOLT_DB.php';

if (!defined('BOLT_DB_PRIVATE_DIR')) {
    define('BOLT_DB_PRIVATE_DIR', '/path/outside/webroot');
}

Status Codes

The router returns predictable status codes:

Code When
404 No matching resource, a private (_-prefixed) path, or a traversal attempt.
405 The resource exists but has no handler for this method. The response includes an Allow header listing the methods that do exist.
204 Returned for a CORS OPTIONS preflight request.

Error Responses

Every error uses one envelope, so clients can handle failures uniformly:

{
    "success": false,
    "errors": [
        { "message": "limit must be positive" }
    ]
}

Handlers produce it by calling api_error($code, $message), which sets the HTTP status, prints the envelope, and exits. Successful responses are whatever the handler echoes; the convention is { "success": true, ... }.

Calling the API from PHP

Server-side code sometimes needs an endpoint's result without an HTTP round-trip — a page that renders data it also exposes as JSON, a cron job, or one handler composing another. bolt_api_request() runs the same handler in process and hands back its response already decoded.

<?php
require_once ROOT_DIR . '/includes/bolt_api.php';   // already loaded inside API handlers

$res = bolt_api_request('api/admin/pages', 'GET', ['q' => 'routing']);

if ($res['ok']) {
    // $res is the handler's JSON decoded to an array, plus 'status' and 'ok'.
    foreach ($res['pages'] as $page) {
        // ...
    }
}

The signature is bolt_api_request($path, $method = 'GET', $data = [], $options = []). The path may include or omit the leading api/, and path parameters are positional just like a URL ('admin/users/jane'). $data is handed to the handler as $body; for GET and HEAD it is also exposed as $_GET.

Option Effect
trusted true runs the call as a synthetic system administrator, bypassing the session auth guards — for cron or CLI contexts with no signed-in user. Off by default, so a call carries exactly the privileges of the current session, just as an HTTP request would.
query An array merged into $_GET regardless of method.

Every return is the handler's decoded JSON with two keys added: status (the HTTP status as an integer) and ok (true for a 2xx status), so a caller can branch on $res['ok'] without parsing anything.

Because the handler runs in process, api_error() cannot exit the way it does over HTTP — that would end the calling script. Inside a bolt_api_request() it instead throws, and the runner catches it and returns the standard error envelope with ok => false and the status. A guard deep inside a handler (say bolt_require_admin()) therefore comes back as a value, not a process exit:

$res = bolt_api_request('api/admin/users', 'POST', ['username' => 'jane', 'password' => 'secret']);

// Anonymous, untrusted context — the auth guard's api_error() is caught and returned:
// $res === [
//   'success' => false, 'ok' => false, 'status' => 401,
//   'errors'  => [['message' => 'Authentication required.']],
// ]

Inside an API handler the layer is already loaded, so one endpoint can compose another directly. The example extension's GET /api/example/status (in the explorer below) builds its response from in-process calls to api/example/ping and core's api/auth/me; a page or cron script only needs require_once ROOT_DIR . '/includes/bolt_api.php' first.

Private Files

Any file or directory whose name begins with _ is never routable — along with the method files themselves and template directories. Requests to them return 404. Keep helper libraries in _-prefixed names, or require them from a handler. The reserved _bootstrap.php is the one such file the router loads automatically.

Because a client-supplied segment is only ever turned into a $params string — never an include path — the only files that can execute are method files inside directories built from real path segments.

Adding an Endpoint

Create a directory under api/ and drop in a method file. There is nothing to register and no route table to edit.

api/
  search/
    GET.php                 → GET  /api/search
  chat/
    POST.php                → POST /api/chat
    test/
      GET.php               → GET  /api/chat/test
  reports/
    _bootstrap.php          (shared setup, auto-loaded)
    summary/
      GET.php               → GET  /api/reports/summary
    export/
      POST.php              → POST /api/reports/export
  widgets/
    GET.php                 → GET  /api/widgets

Example Routes

Request Result
GET /api/search?q=cms api/search/GET.php (query in $_GET)
POST /api/chat api/chat/POST.php (body in $body)
GET /api/reports/2026-q2/summary api/reports/summary/GET.php, $params = ['2026-q2']
DELETE /api/search 405 (only GET.php exists; Allow: GET)
GET /api/reports/_bootstrap 404 (private, _-prefixed)
GET /api/nope 404 (no matching resource)

Request & Response Examples

Each example below is a real endpoint from the api/ tree. Pick a tab to see the request that triggers it, the handler file that runs, and the JSON it sends back — the request, the script, and the payload, side by side.

GET /api/search

A read endpoint driven entirely by the query string. It trims q, clamps limit to 1–50, and returns ranked matches across the docs and product pages. An empty query returns an empty result set, never an error.

Request cURL
curl "https://your-bolt-site.com/api/search?q=routing&limit=3"
Handler api/search/GET.php
<?php
/**
 * GET /api/search?q={query}&limit={n}
 * Full-text search across the site (docs, products, pages).
 *
 * Routed via the API front controller; Content-Type and CORS are set there.
 */

require_once(__DIR__ . '/../../includes/site_search.php');

$query = isset($_GET['q']) ? trim($_GET['q']) : '';
$limit = isset($_GET['limit']) ? intval($_GET['limit']) : 10;
$limit = max(1, min($limit, 50));

if (empty($query)) {
    echo json_encode(['success' => true, 'query' => '', 'results' => []]);
    exit;
}

$searcher = new SiteSearcher();
$results = $searcher->search($query, $limit);

echo json_encode([
    'success' => true,
    'query'   => $query,
    'results' => $results
]);
Response 200 OK
{
  "success": true,
  "query": "routing",
  "results": [
    {
      "title": "Routing",
      "url": "/docs/routing",
      "category": "docs",
      "preview": "Bolt's file-based router maps each clean URL to a page directory — there is no route table to maintain.",
      "score": 47
    },
    {
      "title": "API Routing",
      "url": "/docs/api-routing",
      "category": "docs",
      "preview": "The API uses the same file-based idea, but it is method-aware: a URL under /api/ maps to a handler file.",
      "score": 23
    }
  ]
}

Responses are shown formatted for readability; the wire format is minified JSON.

Routing Layouts & templates