Problem

You need one or two JSON endpoints alongside your pages (a "current visitor count" widget, a small webhook receiver, a status probe for monitoring). A plugin and a router library are both more machinery than the case warrants.

Recipe

The same _ext.php hook that Build a Module's Theme Integration chapter uses for its Router can serve one endpoint inline, with no plugin and no router library. Put the dispatch logic directly in the file:

// themes/<your-theme>/_ext.php

declare(strict_types=1);

$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?? '/';

if ($path === '/api/heartbeat') {
    header('Content-Type: application/json');
    echo json_encode([
        'ok'   => true,
        'time' => date(DATE_ATOM),
    ], JSON_THROW_ON_ERROR);
    return;
}

$site = new \Scriptor\Boot\Frontend\Site(
    \Scriptor\Boot\App::container(),
    $config,
    dirname(__DIR__, 2),
);
$site->execute();

Three pieces:

  • The path check runs before any Scriptor code. $path is the raw URL path; for the few endpoints this recipe targets, an equality test or a str_starts_with is enough.
  • The return exits the include scope, which Scriptor's public/index.php treats as "the theme handled the request, do not run template.php". The same contract every _ext.php uses.
  • The fall-through to Site::execute() is the default request-handling path that runs for every URL the endpoint block did not claim.

That is the whole endpoint. The request never reaches Scriptor's page resolver, so no PageResolving listener fires, no PageRepository lookup runs, no template renders.

Variants

POST body parsing

if ($path === '/webhook/stripe' && ($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
    $body    = file_get_contents('php://input') ?: '';
    $payload = json_decode($body, associative: true, flags: JSON_THROW_ON_ERROR);

    // ... verify signature, persist, etc.

    http_response_code(204);
    return;
}

Two or three endpoints with a small dispatch

$payload = match (true) {
    $path === '/api/heartbeat'           => ['ok' => true, 'time' => date(DATE_ATOM)],
    $path === '/api/build'               => ['commit' => trim((string) @file_get_contents(__DIR__ . '/../../.commit'))],
    str_starts_with($path, '/webhook/')  => handleWebhook($path, (string) file_get_contents('php://input')),
    default                              => null,
};

if ($payload !== null) {
    header('Content-Type: application/json');
    echo json_encode($payload, JSON_THROW_ON_ERROR);
    return;
}

Past three or four endpoints, the match arms turn into a small switch that nobody enjoys reading. That is the cue to switch to either the hand-written router from Build a Module or the FastRoute-wrap recipe, depending on how many routes you expect.

See also