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. null means "no route claimed this request".
  • $response->send() emits the status, headers, and body. The caller's responsibility ends here.
  • Returns bool so the theme's _ext.php can 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, before Router::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.php loaded 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:

  1. Load routes.php so the Router knows about the routes before we ask it to match.
  2. Run the Router and bail out if it claimed the request. The return here is from the include scope (Scriptor's public/index.php did include $ext); it tells the front controller "we are done, do not run template.php".
  3. Fall through to a default Site for everything the Router did not claim. This is what public/index.php would have done on its own if _ext.php did not exist; you are just replaying it so the file works end-to-end. Theme subclasses replace this line with new 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.php did not load routes.php (the Router has no routes registered).
  • The Router::handle() line is after the theme instantiated its Site and called execute().
  • The autoloader has not been refreshed since the plugin was installed (run composer dump-autoload from the Scriptor root, or just reload the page once after composer install).

What just happened, in five sentences

  1. 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.
  2. You wrote a routes.php next to your theme's _ext.php that declares the site's API endpoints by calling $router->get/post, establishing the project convention for where route declarations live.
  3. 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).
  4. The hook sits in _ext.php rather than as a PageResolving listener 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.
  5. curl /api/hello now returns real JSON, /api/users/42 echoes 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.php has the static handle() method as shown.
  • themes/<your-theme>/routes.php exists and declares at least /api/hello and /api/users/{id}.
  • themes/<your-theme>/_ext.php loads routes.php and runs Router::handle() before instantiating the theme's Site.
  • curl /api/hello returns 200 with the expected JSON body.
  • curl /api/users/42 returns 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.