Your first route
Routing is url segments mapped onto files and static methods. There is no route table to
register; Config/Routing.php only supplies the default route and any rewrites.
The routing configuration
Section titled “The routing configuration”<?php
$config['routing'] = [ '' => 'Site/Home/index', '^products/([a-z-]+)$' => 'shop/catalogue/show/$1',];The empty key is the default route and is not optional: without it the router throws a
RouterException reading Missing default routing configuration: $config['routing'][''].
Its value is module/class/method, all three parts required - anything shorter is a
RouterException as well.
Every other key is a regular expression matched against the request url once the script
path and the query string have been stripped off, and before any url prefix is removed.
The key is used as the pattern with # delimiters, and the value as the replacement, so
backreferences work as they do in preg_replace(). The rules are tried in reverse order and the first one that changes the
url wins.
The url as it arrived is captured into Router::$initial_segments at the moment a rule
matches, but it does not stay whole: the prefix, module, controller and method segments are
stripped off it alongside the rewritten ones. By the time a controller runs it holds the
original arguments rather than the original url - and nothing at all when the rewrite
consumed every segment, which is the case for the /products/blue-widget rule above.
The controller
Section titled “The controller”The default route above names Site/Home/index, which is
APP_MODULES_PATH/Site/Controllers/Home.php:
<?php
namespace Site\Controllers;
use StaticPHP\Core\Controllers\Controller;
class Home extends Controller{ public static function index() { $data = ['title' => 'Home'];
self::render(['home.php'], $data); }
public static function show(string $slug, string $tab = 'summary') { return "{$slug} - {$tab}"; }}The module name is the namespace root, and the class sits in the module’s Controllers
directory - that pairing is what the application autoloader resolves, and it is why the
namespace is not PSR-4.
Methods are static. Extending StaticPHP\Core\Controllers\Controller is optional but
gives you a construct() hook that publishes the current urls to the view data, plus:
<?php
public static function render(array $views, array $view_data = []): void;public static function write(string|array $contents): void;
public static function moduleUrl(): string;public static function controllerUrl(): string;public static function methodUrl(): string;render() prefixes each view name with the current module and its Views directory, so
self::render(['home.php']) renders Site/Views/home.php, and hands the result to
Load::view(). Without twig installed that path is resolved under APP_MODULES_PATH;
with twig it is a template name resolved by the loader, which searches
APP_MODULES_PATH, APP_PATH and SP_PATH/Core/Views. render() takes its view data by
value, so an array literal is fine there; Load::view() itself declares $data by
reference, so a call straight to it has to pass a variable.
The view
Section titled “The view”That file has to exist. render() names it but does not create it, and a route whose view
is missing is a 500 rather than an empty page - a Twig\Error\LoaderError when twig is
installed. Without twig the failure is a RuntimeException rather than the require
itself: Load::view() checks the resolved path with Router::pathIsWithin() first, and
realpath() of a file that does not exist returns false, so the check fails before
require ever runs. The message names the wrong cause - View outside of the modules directory: "Site/Views/home.php" reads like a path escaping APP_MODULES_PATH, but the
file is simply missing. Write APP_MODULES_PATH/Site/Views/home.php:
<!DOCTYPE html><html lang="en"><head> <meta charset="utf-8"> <title><?= htmlspecialchars($title, ENT_QUOTES, 'UTF-8') ?></title></head><body> <h1><?= htmlspecialchars($title, ENT_QUOTES, 'UTF-8') ?></h1> <p>Served by <?= htmlspecialchars($module_url, ENT_QUOTES, 'UTF-8') ?></p></body></html>$title is the key the controller put in $data. $module_url comes for free: extending
Controller runs construct(), which publishes current_url, module_url,
controller_url, method_url, module, controller, class and method into
Config::$items['view_data'], and Load::view() merges that into every view’s data. There
is no automatic escaping on this path, which is why the two values above are escaped by
hand.
Requesting / now serves:
<!DOCTYPE html><html lang="en"><head> <meta charset="utf-8"> <title>Home</title></head><body> <h1>Home</h1> <p>Served by http://localhost/site</p></body></html>What the url maps to
Section titled “What the url maps to”The first segment is the module, the following segments locate the controller file, and
the segment after it is the method. Segments are converted to class and method names by
splitting on - and upper-casing each part, so /site/home/order-history calls
Site\Controllers\Home::orderHistory().
| Request | Calls | Arguments |
|---|---|---|
/site/home/show/blue-widget |
Home::show() |
'blue-widget' |
/site/home/show/blue-widget/reviews |
Home::show() |
'blue-widget', 'reviews' |
/site/home |
Home::index() |
none |
/site |
Home::index() |
none |
/products/blue-widget |
Shop\Controllers\Catalogue::show() |
'blue-widget' |
Whatever is left after the method segment is passed to the method as positional string
arguments. When the url stops at the controller, the method falls back to the default
route’s method; when it stops at the module, the class falls back to the default route’s
class as well - so /site above lands on Home::index() because the default route names
Home/index.
Two limits are worth knowing before designing urls:
- Every segment must look like an identifier. After the dash conversion each one has
to match
/^[a-zA-Z][a-zA-Z0-9_-]*$/, arguments included, because the same segments end up in a file path and a class name. A purely numeric segment does not match, so/site/home/show/42finds no controller at all and returns a 404 rather than callingshow('42'). Slugs work; numeric ids have to travel in the query string or the body. - Only public static methods are reachable. Reflection can invoke private and
protected methods without ceremony, so the router checks visibility explicitly;
construct,destruct,__construct,__destructand__callStaticare excluded too, since the router calls those itself. Anything else on the class would otherwise be an endpoint.
What a controller returns
Section titled “What a controller returns”Returning is optional - the method may print or render whatever it likes. If it does return something, the router acts on the type:
- an array is sent as
application/json; charset=utf-8andjson_encoded, - a string or a number is echoed as is,
- an
ErrorMessageis rendered through its ownoutputMessage(), nullproduces no output.
When the class also has a construct() hook, its return value is combined with the
method’s: two arrays are merged, anything else is concatenated as a string. A construct()
that returned an array and a method that returned something else is a RouterException.
The reverse is not checked and falls into the concatenation.
When nothing matches
Section titled “When nothing matches”Failures split into the client’s fault and ours, and the two are treated differently.
A url that does not resolve raises StaticPHP\Core\Exceptions\ErrorMessage\NotFound, a
404. That covers both cases: no controller file for the path, and a method the class does
not have with no __callStatic to absorb it. Its siblings BadRequest (400, raised for an
untrusted or malformed Host header) and Forbidden (403) work the same way.
Router::init() renders these and stops there - deliberately not logged and not emailed,
so a crawler walking dead urls cannot page anyone.
RouterException is the other half, and means the application is misconfigured: no
default route, a malformed one, a default controller that does not exist, or a controller
file that loaded without declaring the class the url named. It is a plain \Exception, so
it lands in the generic branch of Router::init(), which wraps it in a 500, renders it,
and then logs and emails it according to $config['logging'].
Either way the response format follows the request: json in, json out, and likewise for
xml, plain text and html. For html there are two pages, both in src/Core/Views/Errors/:
debug.php, shown only when debug is on, with the exception chain, source excerpts, the stack trace and the request state, with anything that looks like a credential redacted.status.php, shown to everyone else: the status code, a plain sentence explaining it, and a reference id for 5xx so the page can be correlated with the log. No message, no path, no trace.
Both are self-contained single documents with inline css and no external requests, because the page that has to render when something broke is the worst possible place to depend on the template engine or the network. Either can be replaced per application:
<?php
$config['error_pages'] = [ 'status' => APP_PATH . '/Views/Errors/status.php', 'debug' => null,];That is a working application: a front controller, a configuration, a route, a controller and a view. Everything after this is reference, and the Core section is where it starts:
- The boot sequence - the eleven steps
Bootstrap::FILEruns, and the ordering constraint behind each. - The router - prefixes, url helpers, content type
negotiation and the
before_controllerhook. - Controllers - the
construct()anddestruct()hooks, and__callStaticdispatch for urls no method matches. - Load - how views, configs and helpers resolve to files, and what a view is and is not allowed to see.
- Errors - the exception hierarchy behind the 404 above, and what gets logged, emailed or shown.
Beyond Core: the database layer, internationalisation, the audit trail, the utilities and the table subsystem.