Chapter 3 left the Router able to match a path against registered
routes but with no way to actually invoke anything. This chapter
adds the two value objects that handlers consume and produce
(Request in, Response out) and the dispatch() method that
glues match-and-invoke into one call.
Like the Router itself, none of this touches $_SERVER or header()
or echo yet. Everything is data: a Request you can construct in a
unit test, a Response you can assert on. Chapter 5 is the first
time anything goes over the wire.
What you'll write
Two new files under src/:
Request.php, the readonly value object describing the incoming HTTP call: method, path, query, body, headers, raw body, plus the path parameters the Router extracted during match.Response.php, the mutable builder for outgoing replies: status, headers, body, with convenience constructors for JSON / HTML / text / redirect responses.
Plus one method added to Router.php: dispatch(Request $req): ?Response.
After this chapter you can:
$r = Router::instance();
$r->get('/api/users/{id}', function (Request $req): Response {
return Response::json(['id' => (int) $req->param('id')]);
});
$response = $r->dispatch(new Request('GET', '/api/users/42'));
// $response->status = 200
// $response->headers = ['Content-Type' => 'application/json']
// $response->body = '{"id":42}'
The Request was constructed by hand here. Chapter 5's _ext.php
hook will build it from $_SERVER via a Request::fromGlobals()
factory that you write in this chapter too.
The Request value object
Create src/Request.php:
<?php
declare(strict_types=1);
namespace Bigins\ScriptorSimpleRouter;
final readonly class Request
{
/**
* @param string $method Uppercase HTTP method.
* @param string $path URL path, leading slash, no query string.
* @param array<string, string> $query Parsed `$_GET`.
* @param array<string, mixed> $body Parsed body params (form-encoded or JSON-decoded).
* @param array<string, string> $pathParams Captured from the route pattern by Router::match().
* @param array<string, string> $headers Lowercase-key header map.
* @param string $rawBody Untouched request body. Empty for GET.
*/
public function __construct(
public string $method = 'GET',
public string $path = '/',
public array $query = [],
public array $body = [],
public array $pathParams = [],
public array $headers = [],
public string $rawBody = '',
) {}
/**
* Return a new instance with the given path-parameter map.
* Used by Router::dispatch() after a successful match.
*/
public function withPathParams(array $pathParams): self
{
return new self(
method: $this->method,
path: $this->path,
query: $this->query,
body: $this->body,
pathParams: $pathParams,
headers: $this->headers,
rawBody: $this->rawBody,
);
}
public function param(string $name, ?string $default = null): ?string
{
return $this->pathParams[$name] ?? $default;
}
public function input(string $name, mixed $default = null): mixed
{
return $this->body[$name] ?? $default;
}
public function get(string $name, ?string $default = null): ?string
{
return $this->query[$name] ?? $default;
}
public function header(string $name, ?string $default = null): ?string
{
return $this->headers[strtolower($name)] ?? $default;
}
/**
* Build a Request from PHP's superglobals. Called once per
* request from chapter 5's `_ext.php` hook.
*/
public static function fromGlobals(): self
{
$method = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET'));
$path = (string) parse_url((string) ($_SERVER['REQUEST_URI'] ?? '/'), \PHP_URL_PATH);
// Header map: getallheaders() exists under Apache/FPM but
// not on every SAPI. The HTTP_* fallback keeps the Request
// testable without binding to a specific server.
$headers = [];
foreach ($_SERVER as $key => $value) {
if (str_starts_with($key, 'HTTP_')) {
$name = strtolower(str_replace('_', '-', substr($key, 5)));
$headers[$name] = (string) $value;
}
}
$rawBody = (string) file_get_contents('php://input');
$contentType = strtolower($headers['content-type'] ?? '');
$body = match (true) {
str_contains($contentType, 'application/json') && $rawBody !== '' =>
(array) (json_decode($rawBody, true) ?? []),
default => $_POST,
};
return new self(
method: $method,
path: $path,
query: $_GET,
body: $body,
headers: $headers,
rawBody: $rawBody,
);
}
}
Five things worth pointing out:
- Readonly class. PHP 8.2's
readonlykeyword on the class itself locks every property after construction. Handlers cannot mutate the Request they received. If a handler needs a modified version,withPathParams()returns a fresh one (PSR-7-style immutability without the PSR-7 ceremony). pathParamsis a constructor argument. It defaults to[], so a hand-rollednew Request('GET', '/...')works for tests. Router::dispatch() fills it viawithPathParams()after match.fromGlobals()is the only place superglobals are touched. Everything else takes already-parsed data. The Cookbook's testing recipe leans hard on this separation.- JSON bodies are parsed into
$bodywhen the request advertisesContent-Type: application/json. Form-encoded posts land in$_POSTand pass straight through. Raw body stays available on$rawBodyfor handlers that need it (webhook signature verification, file uploads). - Header keys are lowercased. HTTP headers are case-insensitive
on the wire; lowercasing on store, then lowercasing on lookup,
removes a class of "why does
Content-Typework butcontent-typenot" bugs.
The Response builder
Create src/Response.php:
<?php
declare(strict_types=1);
namespace Bigins\ScriptorSimpleRouter;
final class Response
{
/**
* @param int $status HTTP status code.
* @param array<string, string> $headers Header map. Names are
* case-insensitive on the wire;
* this class stores them verbatim.
* @param string $body Response body.
*/
public function __construct(
public int $status = 200,
public array $headers = [],
public string $body = '',
) {}
public static function json(mixed $data, int $status = 200): self
{
return new self(
status: $status,
headers: ['Content-Type' => 'application/json'],
body: (string) json_encode($data, \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE),
);
}
public static function text(string $text, int $status = 200): self
{
return new self(
status: $status,
headers: ['Content-Type' => 'text/plain; charset=utf-8'],
body: $text,
);
}
public static function html(string $html, int $status = 200): self
{
return new self(
status: $status,
headers: ['Content-Type' => 'text/html; charset=utf-8'],
body: $html,
);
}
public static function redirect(string $location, int $status = 302): self
{
return new self(
status: $status,
headers: ['Location' => $location],
body: '',
);
}
public function status(int $status): self
{
$this->status = $status;
return $this;
}
public function header(string $name, string $value): self
{
$this->headers[$name] = $value;
return $this;
}
/**
* Emit the response: status line, headers, body. Called once
* per request from Router::handle() in chapter 5.
*/
public function send(): void
{
if (! headers_sent()) {
http_response_code($this->status);
foreach ($this->headers as $name => $value) {
header($name . ': ' . $value, true);
}
}
echo $this->body;
}
}
Three patterns worth naming:
- Static factories cover the 95% case.
Response::json($data)is the common path; you only reach for the constructor when you're building something exotic. The factories pre-setContent-Typeso handlers cannot forget. status()andheader()return$thisfor fluent chaining:Response::text('OK')->status(201)->header('X-Total-Count', '42'). Trade-off taken: this Response is mutable. PSR-7 immutability is cleaner architecturally but doubles the line count for the 80% case. The Cookbook covers the immutable variant.send()is the only side-effecting method. It checksheaders_sent()first so a misbehaving handler that echoed output before returning the Response (don't, but it happens) fails to emit the status line instead of throwing a fatal.
Wire dispatch into Router
Open src/Router.php and add two methods:
/**
* Match the request against registered routes and invoke the
* matching handler. Returns the handler's Response, or null if
* no route matched (the caller decides what to do then; usually
* fall through to the next handler in the pipeline).
*/
public function dispatch(Request $request): ?Response
{
$hit = $this->match($request->path, $request->method);
if ($hit === null) {
return null;
}
$request = $request->withPathParams($hit['params']);
$response = self::invoke($hit['route']->handler, $request);
if (! $response instanceof Response) {
throw new \LogicException(sprintf(
'Handler for %s %s must return a %s, got %s.',
$hit['route']->method,
$hit['route']->pattern,
Response::class,
get_debug_type($response),
));
}
return $response;
}
/**
* Resolve any of the three supported handler shapes into a call
* with the Request as its only argument.
*
* - Closure -> $handler($request)
* - 'App\Controller' -> (new App\Controller())($request) // __invoke
* - ['App\Controller', 'm'] -> (new App\Controller())->m($request)
*/
private static function invoke(mixed $handler, Request $request): mixed
{
if ($handler instanceof \Closure) {
return $handler($request);
}
if (\is_string($handler) && class_exists($handler)) {
return (new $handler())($request);
}
if (\is_array($handler) && \count($handler) === 2 && \is_string($handler[0]) && \is_string($handler[1])) {
[$class, $method] = $handler;
return (new $class())->$method($request);
}
throw new \LogicException('Unsupported handler shape: ' . get_debug_type($handler));
}
Two design calls worth surfacing:
dispatch()returns?Response, notResponse. A null return distinguishes "no route matched" from "the matched handler returned a 204 No Content". Chapter 5's_ext.phpreads the null as "let Scriptor's normal page resolver take this request".- Handler instantiation is zero-argument (
new $class()). Real DI containers would resolve constructor dependencies here; Simple Router does not, because the moment it does, it becomes a DI container shim. Handlers that need dependencies should receive them via a factory closure registered as the route handler. The Cookbook has the pattern.
Verify in isolation
Extend the verify.php from chapter 3:
<?php
declare(strict_types=1);
require_once __DIR__ . '/vendor/autoload.php';
use Bigins\ScriptorSimpleRouter\Request;
use Bigins\ScriptorSimpleRouter\Response;
use Bigins\ScriptorSimpleRouter\Router;
$r = Router::instance();
$r->get('/api/users/{id}', function (Request $req): Response {
return Response::json(['id' => (int) $req->param('id'), 'name' => 'Ada']);
});
$r->post('/echo', function (Request $req): Response {
return Response::json(['you-sent' => $req->body])->status(201);
});
$cases = [
new Request('GET', '/api/users/42'),
new Request('POST', '/echo', body: ['hello' => 'world']),
new Request('GET', '/nope'),
];
foreach ($cases as $req) {
$resp = $r->dispatch($req);
if ($resp === null) {
printf("%-6s %-22s -> no match\n", $req->method, $req->path);
continue;
}
printf(
"%-6s %-22s -> %d %s\n",
$req->method,
$req->path,
$resp->status,
$resp->body,
);
}
Run it:
php verify.php
Expected output:
GET /api/users/42 -> 200 {"id":42,"name":"Ada"}
POST /echo -> 201 {"you-sent":{"hello":"world"}}
GET /nope -> no match
Three things proven in twelve lines:
- Path parameters reach the handler via
$req->param(). - Body data injected at construction time round-trips through the handler back into the Response.
dispatch()correctly distinguishes match from no-match.
No HTTP, no $_SERVER, no headers. Just data in, data out. That is
the entire point of building Request and Response as plain values:
chapter 6's tests are the same shape, with a PHPUnit assertion
instead of a printf.
What just happened, in five sentences
- You wrote a readonly Request that wraps method, path, query,
body, headers, raw body, and path params, with a
fromGlobals()factory that is the only place superglobals enter the plugin. - You wrote a mutable Response builder with four static factories
(JSON, HTML, text, redirect), fluent
status()andheader()setters, and asend()method that emits headers and body exactly once. - You added
Router::dispatch()which callsmatch(), fills path params onto the Request, invokes the matched handler with the three supported shapes (Closure, FQCN,[Class, method]), and returns the resulting Response. - A
nullreturn fromdispatch()means "no route matched", which chapter 5 turns into "let Scriptor's normal page resolver take this request". verify.phpexercises the round-trip end-to-end with three constructed Request objects and reads back the resulting status and body, no HTTP involved.
The plugin now has everything except the actual hook into a real request. Chapter 5 writes the three-line glue.
To-do before chapter 5
-
src/Request.phpexists with the readonly value object,withPathParams(), the four accessors (param,input,get,header), and thefromGlobals()factory. -
src/Response.phpexists with the constructor, four static factories (json, text, html, redirect), the fluentstatus()andheader()setters, andsend(). -
src/Router.phphasdispatch(Request): ?Responseand the privateinvoke()resolver covering all three handler shapes. - Running
php verify.phpfrom the plugin root prints the three expected output lines. - The plugin still loads in your local Scriptor (the
[scriptor-simple-router] plugin registered (no-op)log line is still there on every page reload).
When all five boxes are ticked, open chapter 5 and turn this
in-memory dispatch into a real HTTP intercept via _ext.php.
Behind the scenes. The Request::fromGlobals() shape is a
deliberate simplification of PSR-7's ServerRequestInterface plus
ServerRequestFactory::createFromGlobals(). PSR-7 brings stream
bodies, attribute bags, uploaded-file objects, and immutable with*
chains; Simple Router takes the 90% case and skips the rest. The
Concepts → Frontend Events chapter
explains where in Scriptor's own request flow your dispatch()
will eventually sit.