This is the load-bearing chapter of the track. Everything so far
worked in isolation: the Router matched paths, dispatch() invoked
handlers, Response built JSON. Nothing went over the wire.
This chapter writes the bridge. It is small (one static method on
the Router, one routes file, one line in _ext.php) but it is the
only piece that depends on Scriptor's request pipeline, so it gets
its own chapter to explain why the bridge sits exactly where it
sits and not somewhere "cleaner-looking" like a PSR-14 listener.
Why _ext.php, not a PSR-14 listener
Scriptor's normal plugin extension point for the frontend is the
PageResolving event. scriptor-markdown-pages uses it. Why does
this plugin not?
Because PageResolving::$resolution is typed ?Page. A listener
that wants to claim a request fills the slot with a Page object.
That contract is right for "I have a page-shaped thing to serve"
(a markdown file, a generated landing, a virtual archive page);
it is wrong for "I have a JSON response to emit".
To force a router-style endpoint through PageResolving, you would
have to fake a Page whose content is JSON, then short-circuit the
template-rendering step that wraps that content in HTML. The route
would always go through the page-resolution pipeline that exists
to produce HTML pages, just to skip 80% of it. That is the cure
that does more damage than the disease.
_ext.php is Scriptor's theme-level extension point. It runs in
public/index.php (lines 53-67) before PageResolving fires,
and it has the power to do anything (write headers, emit a body,
return), or to delegate to the normal pipeline by instantiating a
Site subclass as usual. Exactly the shape a router needs. The
trade-off is that the hook lives in the theme, not the plugin.
This chapter is about making that trade-off cheap.
Add Router::handle()
The plugin's Router so far has dispatch(Request) for in-memory
use and lacks a one-call entry point that builds a Request from
the real environment, dispatches, and sends the Response. Add
that static method.
Open src/Router.php and add:
/**
* One-call bridge from the live request to a sent Response.
* Returns true when a route matched (and the Response was
* sent), false when nothing matched (and the caller should
* continue the normal pipeline).
*
* Used from a theme's `_ext.php` as:
*
* if (\Bigins\ScriptorSimpleRouter\Router::handle()) return;
*/
public static function handle(): bool
{
$request = Request::fromGlobals();
$response = self::instance()->dispatch($request);
if ($response === null) {
return false;
}
$response->send();
return true;
}
Six executable lines. Walk them:
Request::fromGlobals()is the only place superglobals enter the request flow (the contract you set in chapter 4).self::instance()->dispatch($request)runs the matcher and invokes the handler.nullmeans "no route claimed this request".$response->send()emits the status, headers, and body. The caller's responsibility ends here.- Returns
boolso the theme's_ext.phpcan decide between "router consumed it, stop" and "router passed, continue with normal page resolution".
handle() is the only piece of glue between the testable Router
and the live request. Chapter 6's test suite never calls it; tests
construct Request objects directly and call dispatch(). handle()
is for production code only.
Decide where to register routes
The Router needs routes before it can match. Three reasonable places to call the verb methods, each with different ergonomics:
- In the theme's
_ext.php, beforeRouter::handle(). Simplest. No new files. Works for sites with a handful of routes. Mixes application code (route declarations) with theme bootstrap, which gets noisy past five or six routes. - In a dedicated
routes.phploaded by_ext.php. Same performance, separation of concerns. The convention this chapter recommends. - In the plugin's
Plugin::register(), reading a config-driven list of route files. Maximum decoupling, but requires the plugin to know how to find the site's route files. The Cookbook covers this for sites that ship multiple themes.
Pick the middle option. From now on the convention is: every site
that uses Simple Router has a routes.php next to its theme's
_ext.php, and the latter loads the former.
Create themes/<your-theme>/routes.php:
<?php
declare(strict_types=1);
use Bigins\ScriptorSimpleRouter\Request;
use Bigins\ScriptorSimpleRouter\Response;
use Bigins\ScriptorSimpleRouter\Router;
$router = Router::instance();
$router->get('/api/hello', static function (Request $req): Response {
return Response::json([
'hello' => 'world',
'time' => date(DATE_ATOM),
]);
});
$router->get('/api/users/{id}', static function (Request $req): Response {
return Response::json([
'id' => (int) $req->param('id'),
'name' => 'Ada Lovelace',
]);
});
Two routes for the verification step. Real sites grow this file
organically; one common pattern is to keep routes.php short and
delegate the actual logic to controller classes you register with
the [Class, method] handler shape.
The one-line _ext.php hook
Two scenarios. Pick the one that matches your theme.
Scenario A: theme has no _ext.php yet
Most themes do not. The Atelier theme from Build-a-Theme does not.
Create themes/<your-theme>/_ext.php:
<?php
declare(strict_types=1);
require_once __DIR__ . '/routes.php';
if (\Bigins\ScriptorSimpleRouter\Router::handle()) return;
$site = new \Scriptor\Boot\Frontend\Site(
\Scriptor\Boot\App::container(),
$config,
dirname(__DIR__, 2),
);
$site->execute();
Three logical sections:
- Load
routes.phpso the Router knows about the routes before we ask it to match. - Run the Router and bail out if it claimed the request. The
returnhere is from the include scope (Scriptor'spublic/index.phpdidinclude $ext); it tells the front controller "we are done, do not runtemplate.php". - Fall through to a default
Sitefor everything the Router did not claim. This is whatpublic/index.phpwould have done on its own if_ext.phpdid not exist; you are just replaying it so the file works end-to-end. Theme subclasses replace this line withnew MyTheme(...).
The Router::handle() line is the only one our plugin contributes.
The rest is generic Scriptor theme bootstrap, identical to what any
other _ext.php would do.
Scenario B: theme already has an _ext.php
The bundled basic theme has one, and so might yours. The
integration is literally a single new line, inserted before the
theme instantiates its Site subclass:
<?php
declare(strict_types=1);
require_once __DIR__ . '/vendor/autoload.php';
require_once __DIR__ . '/routes.php'; // <-- new
if (\Bigins\ScriptorSimpleRouter\Router::handle()) return; // <-- new
// ... existing theme code below (cache check, $site = new MyTheme(...), etc.)
Two new lines, both above the existing logic. Order matters: the Router check must come before whatever your theme does with the request. If the theme caches output and you check the cache first, a cached HTML response will short-circuit the router and your API endpoints will never run.
A word on cache ordering
If your theme runs a SuperCache short-circuit at the top of
_ext.php (basic theme does this around line 24), you have two
ways to combine it with the Router:
- Router first (recommended for most sites): the Router runs on every request, the cache only short-circuits page responses. Predictable, but every request pays the route-match cost.
- Cache first: cached responses skip the Router entirely. A cached page renders faster, but POST and webhook routes never match against a cached request, so the cache key has to include the method and you need a way to bypass the cache for non-GET requests. This is the basic theme's shape today.
The trade-off is real. Sites that ship a JSON API alongside a
mostly-static frontend usually pick "router first" because the
extra microseconds per cached page are invisible compared to the
debugging cost of "why does my POST endpoint sometimes return
HTML". The basic theme keeps "cache first" because its router
(BasicRouter) is page-flow-only and the cache never holds API
responses.
There is no universal right answer. Pick the one that matches the mix of routes and cached pages your site actually has.
Verify end-to-end
Reload http://localhost:<port>/api/hello in a browser or with
curl:
$ curl -i http://localhost:<port>/api/hello
HTTP/1.1 200 OK
Content-Type: application/json
...
{"hello":"world","time":"2026-05-23T15:42:00+00:00"}
And the path-param case:
$ curl -i http://localhost:<port>/api/users/42
HTTP/1.1 200 OK
Content-Type: application/json
...
{"id":42,"name":"Ada Lovelace"}
And a path that does not match any route should fall through to Scriptor's normal page resolution. If the URL also is not a Scriptor page, you get the usual 404:
$ curl -i http://localhost:<port>/nope/
HTTP/1.1 404 Not Found
If /api/hello returns the HTML of Scriptor's home page instead of
the expected JSON, the most likely cause is one of:
_ext.phpdid not loadroutes.php(the Router has no routes registered).- The
Router::handle()line is after the theme instantiated itsSiteand calledexecute(). - The autoloader has not been refreshed since the plugin was
installed (run
composer dump-autoloadfrom the Scriptor root, or just reload the page once aftercomposer install).
What just happened, in five sentences
- You added
Router::handle(), a six-line static method that builds a Request from globals, dispatches it, sends the resulting Response, and returns a bool telling the caller whether anything matched. - You wrote a
routes.phpnext to your theme's_ext.phpthat declares the site's API endpoints by calling$router->get/post, establishing the project convention for where route declarations live. - You added the one-line
Router::handle()check to your theme's_ext.php, either by creating the file from scratch (vanilla themes) or by inserting two lines above the existing bootstrap (themes that already have an_ext.php). - The hook sits in
_ext.phprather than as aPageResolvinglistener because PageResolving's slot is typed?Page, which forces JSON endpoints to wear a page-shaped mask just to short-circuit the page-rendering pipeline they should never have entered. curl /api/hellonow returns real JSON,/api/users/42echoes the captured path parameter, and unmatched URLs fall through to Scriptor's normal page resolver.
The plugin is functionally complete. Chapter 6 polishes it for
publication: a small PHPUnit suite, a README, the semver tag, and
the GitHub VCS setup that lets other sites composer require it.
To-do before chapter 6
-
src/Router.phphas the statichandle()method as shown. -
themes/<your-theme>/routes.phpexists and declares at least/api/helloand/api/users/{id}. -
themes/<your-theme>/_ext.phploadsroutes.phpand runsRouter::handle()before instantiating the theme'sSite. -
curl /api/helloreturns 200 with the expected JSON body. -
curl /api/users/42returns 200 with{"id":42,...}. - A URL the Router does not claim still serves its normal Scriptor page (or 404s through Scriptor's usual flow).
When all six boxes are ticked, open chapter 6 and turn the in-progress plugin into a tagged, publishable Composer package.
Behind the scenes. The exact include point for _ext.php is
public/index.php
lines 53-67. The if (! $site instanceof Site) return; check there
is what makes a return from inside _ext.php terminate the
request cleanly. The basic theme's
themes/basic/_ext.php
is the reference implementation of the "cache first, then router"
ordering discussed above; read it once if you're picking that
shape for your own theme.