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.
$pathis the raw URL path; for the few endpoints this recipe targets, an equality test or astr_starts_withis enough. - The
returnexits the include scope, which Scriptor'spublic/index.phptreats as "the theme handled the request, do not runtemplate.php". The same contract every_ext.phpuses. - 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
- Wrap FastRoute or league/route in a plugin: the next step up when the endpoint list grows past two or three
- Controller-with-DI via factory closure: when the endpoint needs services injected (DB, logger, mailer)
- Build a Module: Theme Integration:
the full explanation of why
_ext.phpis the right hook and notPageResolving App: the locator the snippet uses to reach the container without threading it throughSite: the class the fall-through instantiates