Router.php · Documentation
v2.0.0 · stable

Build a fast, secure PHP app in one file.

Router.php is a single-file PHP routing engine: radix-trie matching, file-based response caching, rate limiting, CSRF, CSP, multi-domain static fallback, and SEO — with no Composer dependencies. This guide walks you through every feature, even if today is your first day with PHP.

1 file~96 KB. Drop in. Done.
O(k)Radix trie. k = path segments.
0 depsPure PHP 8.1+, no Composer.
9/10OWASP top-10 covered out of the box.

What is Router.php? #

Router.php is a single PHP file that turns incoming HTTP requests into your code. You list URL patterns, you give each one a function, and Router.php takes care of the rest: finding the right one, running security checks, caching responses, and rendering nice error pages.

Compared to a full framework, it has zero dependencies, zero install ceremony, and a tiny mental footprint. Compared to a hand-rolled switch ($_SERVER['REQUEST_URI']), it’s safer, faster, and won’t leak session cookies into your cache.

Fast

Radix-trie matching with no regex per request. Hash-key lookups beat any pattern list.

Secure

HSTS, CSP nonce, CSRF (double-submit), rate limit, HMAC-guarded cache, IPv4/IPv6 trusted proxies.

Self-contained

Single file. Pure PHP 8.1+. No Composer, no autoloader, no runtime surprises.

SEO-ready

Auto sitemap, robots.txt, OpenGraph & canonical tags. Keywords with Unicode-aware density check.

Mental model in 3 sentences

  1. Patterns map to functions. Every URL you serve becomes one line: a method (GET/POST), a pattern (/users/{id}), and the function that handles it.
  2. Functions receive two arguments. The first is $params — the named pieces of the URL like {id}. The second is $input — the request body in three forms: get, post, json.
  3. You write the response with echo or header(). Router.php does not impose a Response object; you stay close to the metal.

Install & first run #

Requirements

  • PHP 8.1 or newer (strict_types, readonly properties, named-arg matching).
  • Apache, nginx, IIS, Caddy, or PHP’s built-in server. Any front-controller-friendly host works.
  • Write access to the directory containing Router.php for the cache directory .ncache/.

1 · Drop in the file

Copy Router.php into your web root next to (or anywhere your index.php can reach):

your-app/
├── Router.php
├── index.php          ← your front controller
└── .env               ← created automatically on first run

2 · Write your front controller

<?php
declare(strict_types=1);

require __DIR__ . '/Router.php';

Router::init();              // load .env, emit security headers, rate-limit
Router::get('/', function () {
    echo '<h1>Hello, world.</h1>';
});
Router::dispatch();          // resolve the request and run the matched handler
Why Router::init() is explicit
Including Router.php has zero side effects. The file is safe to autoload, introspect, lint, or include twice. Headers and rate-limiting only happen once you call init().

3 · Run a dev server

php -S 127.0.0.1:8000 index.php

Visit http://127.0.0.1:8000. You should see the greeting. On first request, a fresh .env file is created next to Router.php with a randomly generated APP_SECRET.

4 · Generate server configs (Apache, IIS, nginx)

Run once from CLI when you deploy. This drops a tuned .htaccess, web.config, and an nginx.conf.example next to Router.php.

php -r "require 'Router.php'; Router::init(); Router::generateServerConfigs();"
Heads up
Existing config files are never overwritten. Delete or rename them first if you want a fresh template.

Quick start #

A complete, runnable index.php that demonstrates 90 % of the API:

<?php
declare(strict_types=1);

require __DIR__ . '/Router.php';

Router::init();

// Global middleware: logs every matched request.
Router::use(function (array $params, array $input): bool {
    error_log(($_SERVER['REQUEST_METHOD'] ?? '?') . ' ' . ($_SERVER['REQUEST_URI'] ?? '/'));
    return true; // continue
});

// Cache the home page for 5 minutes (response cache).
Router::get('/', function () {
    echo '<h1>Welcome</h1>';
}, [], ['cacheable' => true]);

// Dynamic param + int constraint.
Router::get('/users/{id:int}', function (array $p) {
    header('Content-Type: application/json');
    echo json_encode(['user_id' => (int) $p['id']]);
}, [], ['name' => 'user.show']);

// Group with shared prefix and middleware.
Router::group('/api/v1', function (): void {
    Router::get('/me', fn () => print json_encode(['user' => 'demo']));
    Router::post('/posts', function (array $p, array $i) {
        echo json_encode(['received' => $i['json']]);
    });
}, [Router::csrfMiddleware()]);

// Optional segment + wildcard.
Router::get('/blog/{year?}',  fn ($p) => print 'blog year ' . ($p['year'] ?? 'all'));
Router::get('/files/{path*}', fn ($p) => print 'serving file ' . $p['path']);

// Custom 404.
Router::setErrorHandler(404, function (int $code, string $message): string {
    return "<h1>Lost?</h1><p>{$message}</p>";
});

Router::dispatch();
That’s the whole engine
Everything below is a deeper dive into one piece of the example above. You can stop here and ship if your app is simple — it’ll be perfectly safe in production.

Routing basics #

Every route registration looks the same:

Router::<method>(
    string   $pattern,                  // e.g. "/users/{id}"
    callable $handler,                  // function(array $params, array $input)
    array    $middleware = [],          // optional middleware list
    array    $options    = []           // 'cacheable' | 'name' | 'rate' | 'seo'
);

HTTP verbs

One method per HTTP verb. Use map() or any() when a handler should answer to several:

Router::get   ('/posts',    fn () => /* … */);   // GET (also handles HEAD automatically)
Router::post  ('/posts',    fn () => /* … */);   // POST
Router::put   ('/posts/{id}', fn () => /* … */); // PUT
Router::patch ('/posts/{id}', fn () => /* … */); // PATCH
Router::delete('/posts/{id}', fn () => /* … */); // DELETE

Router::map(['GET', 'POST'], '/login', $loginHandler);
Router::any('/legacy/{path*}', $legacyHandler);   // matches all common verbs

The handler signature

Your handler receives two arrays:

function (array $params, array $input): void {
    // $params: URL params captured by {placeholders}
    //   e.g. ['id' => '42']
    //
    // $input:
    //   $input['get']   — $_GET (raw)
    //   $input['post']  — $_POST (raw)
    //   $input['json']  — decoded JSON body when Content-Type is application/json
    //   $input['files'] — $_FILES
    //   $input['raw']   — raw body string (only set for JSON requests)
}
$params are sanitised. $input is raw.
Router strips null bytes and normalises UTF-8 on path parameters only. Body inputs (get, post, json) are passed through untouched — escape them with htmlspecialchars() at the view layer, never inside business logic.

Returning a response

You write directly to the output: echo for a body, header() for headers, http_response_code() for status. Convenience patterns:

// JSON
header('Content-Type: application/json');
echo json_encode(['ok' => true]);

// HTML
echo "<h1>Hello {$p['name']}</h1>";

// Redirect
header('Location: /login', true, 302);

// 404
http_response_code(404);
echo 'Not found';

Pattern syntax #

Patterns are made of literal segments and placeholders wrapped in { }.

PatternMatchesCaptures
/aboutExact path /about
/users/{id}Any single segment after /users/id
/posts/{id:int}Digits onlyid
/blog/{slug:slug}Letters, digits, dashes, underscoresslug
/items/{id:uuid}UUID v1–v5id
/mix/{x:[a-z]+}Any custom regexx
/blog/{year?}Both /blog and /blog/2024year (optional)
/files/{path*}Greedy: /files/a/b/c.txt toopath = a/b/c.txt

Built-in constraint aliases

AliasEquivalent regexExample match
int[0-9]+42
alpha[A-Za-z]+hello
alnum[A-Za-z0-9]+abc123
slug[A-Za-z0-9_-]+my-first-post
uuidRFC-4122 hex pattern3f5b…0c91

Match precedence

When two routes could match the same URL, Router.php picks one in this order:

  1. Literal segments win over dynamic ones. /users/me beats /users/{id}.
  2. Constraint match. If a dynamic segment has a constraint, it must match — otherwise traversal continues to the wildcard.
  3. Wildcard ({path*}) matches any remaining path and is always last-resort.
One dynamic param per trie level
You can register /users/{id} and /users/me together — fine. But you can’t register both /users/{id} and /users/{slug}; they would land on the same trie node. Pick one parameter name and re-use it.

Groups & prefixes #

Use Router::group() to share a URL prefix and a middleware stack across many routes:

Router::group('/admin', function (): void {

    Router::get('/dashboard', $dashboardHandler);   // → /admin/dashboard
    Router::get('/users',     $usersHandler);       // → /admin/users

    Router::group('/billing', function (): void {
        Router::get('/invoices', $invoicesHandler); // → /admin/billing/invoices
    });

}, [$adminAuthMiddleware, Router::csrfMiddleware()]);

Groups can be nested as deep as you like; prefixes concatenate, middleware lists merge in order.

Global middleware

If a piece of middleware should run on every matched route — request logging, request-ID tagging, response-time metrics — register it globally:

Router::use(function (array $p, array $i): bool {
    header('X-Request-Id: ' . bin2hex(random_bytes(8)));
    return true;
});

Global middleware runs before group/route middleware in the order it was registered.

Named routes & URL building #

Stop hard-coding URL paths in your templates. Name a route once, generate the URL anywhere:

// Inline:
Router::get('/users/{id}', $showUser, [], ['name' => 'user.show']);

// Or after-the-fact (names the *last* registered route):
Router::get('/posts/{slug}', $showPost);
Router::name('post.show');

// Build a URL from a name:
$url = Router::url('user.show', ['id' => 42]);   // → "/users/42"
$url = Router::url('post.show', ['slug' => 'hi']); // → "/posts/hi"

Use it in views:

<a href="<?= htmlspecialchars(Router::url('post.show', ['slug' => $post->slug])) ?>">
    <?= htmlspecialchars($post->title) ?>
</a>
Refactor-safe links
Renaming a path is now one line — the names stay the same. Missing parameters throw InvalidArgumentException at build time, not at request time.

Middleware #

A middleware is any callable with the same signature as a handler. Return false to short-circuit the request with a 403 Forbidden; return anything else (including null or true) to continue.

$auth = function (array $params, array $input): bool {
    $session = $_SESSION ?? [];
    return !empty($session['user_id']);   // false → 403
};

Router::get('/account', $accountHandler, [$auth]);

Order of execution

  1. Global middleware (registered with Router::use()), in registration order.
  2. Group middleware, outermost group first.
  3. Route-specific middleware (3rd argument to the registration call).
  4. The route handler itself.

Common patterns

// Require admin role
$adminOnly = fn ($p, $i) => ($_SESSION['role'] ?? null) === 'admin';

// Require JSON Content-Type
$jsonOnly = function ($p, $i): bool {
    $ct = $_SERVER['CONTENT_TYPE'] ?? '';
    if (!str_starts_with($ct, 'application/json')) {
        http_response_code(415);
        echo json_encode(['error' => 'JSON required']);
        return false;
    }
    return true;
};

// Tag every response with a request id
Router::use(function ($p, $i): bool {
    header('X-Request-Id: ' . ($_SERVER['HTTP_X_REQUEST_ID'] ?? bin2hex(random_bytes(8))));
    return true;
});

CSRF protection #

Router.php ships a session-stored token + double-submit cookie implementation. It blocks every cross-site form submission as long as you call Router::csrf() in your view and use Router::csrfMiddleware() on state-changing routes.

1 · Render the token in the form

<form method="POST" action="/login">
    <input type="hidden" name="_token" value="<?= htmlspecialchars(Router::csrf()) ?>">
    <input type="email" name="email">
    <input type="password" name="password">
    <button>Sign in</button>
</form>

Calling Router::csrf() also drops a non-HttpOnly cookie called XSRF-TOKEN so single-page apps can echo it back in an X-CSRF-Token header.

2 · Validate on the server

Router::post('/login', $loginHandler, [Router::csrfMiddleware()]);

// Or validate manually:
Router::post('/api/something', function (array $p, array $i) {
    if (!SecurityLayer::csrfValidate($i['post']['_token'] ?? '')) {
        http_response_code(403);
        echo 'CSRF failed';
        return;
    }
    // … safe to mutate …
});

3 · SPA / fetch / Ajax

// Read the cookie value once (set by GET /any-page that calls Router::csrf()).
const xsrf = document.cookie.match(/XSRF-TOKEN=([^;]+)/)?.[1];

await fetch('/api/posts', {
    method: 'POST',
    credentials: 'include',
    headers: {
        'Content-Type': 'application/json',
        'X-CSRF-Token': xsrf,
    },
    body: JSON.stringify({ title: 'Hello' }),
});
Tokens rotate per session
Each session gets a single random token created by the first Router::csrf() call. Call SecurityLayer::csrfRotate() after a successful login to invalidate any stolen value.

CORS #

CORS is off by default. Cross-origin OPTIONS preflights are rejected until you explicitly allow origins:

Router::cors([
    'origins'     => ['https://app.example.com', 'https://admin.example.com'],
    'methods'     => ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
    'headers'     => ['Content-Type', 'Authorization', 'X-CSRF-Token'],
    'credentials' => true,    // include cookies / Authorization
    'max_age'     => 600,     // preflight cache (seconds)
]);

Or via .env:

ROUTER_CORS_ORIGINS=https://app.example.com,https://admin.example.com
Avoid wildcard * with credentials
Browsers reject Access-Control-Allow-Origin: * on credentialed requests. Always list the specific origins you trust.

CSP nonce #

Every request gets a fresh Content-Security-Policy nonce. Inline scripts and styles must carry it — otherwise the browser will block them. This is the modern, correct XSS defence.

<?php $nonce = Router::cspNonce(); ?>
<!doctype html>
<html>
<head>
    <style nonce="<?= $nonce ?>">
        body { font-family: system-ui; }
    </style>
    <script nonce="<?= $nonce ?>">
        console.log('safe');
    </script>
</head>

Allow extra hosts

Need to load Tailwind CDN or Google Fonts? Allowlist them once, before Router::init():

SecurityLayer::allowScriptSources(['https://cdn.tailwindcss.com']);
SecurityLayer::allowStyleSources(['https://fonts.googleapis.com']);

Router::init();

Rate limiting #

A per-IP sliding-window limit is applied globally in Router::init(). Defaults: 120 requests / 60 seconds. Tune with environment variables:

RATE_LIMIT_MAX=120
RATE_LIMIT_WINDOW=60

Per-route override

Tighter limits on sensitive endpoints (login, password reset, signup):

Router::post('/login', $loginHandler, [], [
    'rate' => ['max' => 5, 'window' => 60],
]);

Router::post('/password/reset', $resetHandler, [], [
    'rate' => ['max' => 3, 'window' => 600],
]);

Trusted proxies (Cloudflare, AWS ALB, Azure)

By default Router uses $_SERVER['REMOTE_ADDR'] as the client IP. If you’re behind a load balancer, list its CIDR so forwarded headers are honoured:

# Cloudflare IPv4 + IPv6 ranges (truncated)
TRUSTED_PROXIES=173.245.48.0/20,103.21.244.0/22,2400:cb00::/32,2606:4700::/32

When the IP is in the list, Router reads X-Forwarded-For, CF-Connecting-IP, then X-Real-IP. IPv4 and IPv6 CIDR are both supported.

Response caching #

Mark a GET route cacheable to store its rendered output on disk:

Router::get('/articles/{slug}', $showArticle, [], ['cacheable' => true]);

Behaviour:

  • TTL defaults to RESPONSE_CACHE_TTL (300 s). Tune via .env.
  • The cache key includes the host, path, query string (sorted), and a canonicalised Accept + Accept-Encoding.
  • Set-Cookie headers are stripped from the cached response — no session bleed-over.
  • If the request carries any session cookie or Authorization header, the cache is bypassed on read AND write — authenticated pages are never cached.

Invalidation

// Wipe just the response cache:
Router::flush();

// Wipe everything except logs:
Router::wipeCache();

// Wipe including logs:
Router::wipeCache(true);

Optional remote cache-clear endpoint

Off by default. Set ROUTER_CC_ROUTE=/_cc in .env to enable a token-gated HTTP route that wipes the cache on demand:

# Generate the rotating HMAC token (5-minute window):
TOKEN=$(php -r "require 'Router.php'; Router::init(); echo Router::cacheClearToken();")

# Call the endpoint:
curl "https://example.com/_cc?t=$TOKEN"
Never expose /_cc without a token
The token is HMAC-derived from APP_SECRET. Any route accepting a wipe call without validating it is a free DoS lever for attackers.

Static files & multi-domain #

If your hosting platform can’t set per-domain document roots, Router can serve static assets from a per-host folder as a fallback. Map hosts to folders:

Router::domains([
    'example.com'      => 'sites/example',
    'www.example.com'  => 'sites/example',
    '*.example.net'    => 'sites/example-net',  // wildcard subdomain
]);

Or via .env:

ROUTER_DOMAIN_FOLDERS={"example.com":"sites/example","*.example.net":"sites/net"}

Inside your handlers, the current folder is exposed:

$folder = Router::currentDomainFolder();   // "sites/example" or null
echo file_get_contents(__DIR__ . '/' . $folder . '/templates/home.html');

What gets served

  • Only GET and HEAD requests.
  • Anything inside the mapped folder, except blocked extensions: .php, .env, .log, .sql, .htaccess, etc.
  • Symlinks pointing outside the folder are refused (realpath containment check).
  • Fingerprinted assets (app.a3f9c2.js) get Cache-Control: immutable; everything else gets a 1-hour must-revalidate.

Disable entirely with ROUTER_STATIC_FILE_SERVING=false if your web server already handles assets.

SEO meta & sitemap #

Attach SeoMeta to a route and Router will inject the right tags into the <head> and include the URL in /sitemap.xml:

Router::get('/', function () {
    Router::emitSeoHead();   // call this inside <head>
    require __DIR__ . '/views/home.php';
}, [], [
    'seo' => new SeoMeta(
        title:       'Acme — best widgets in town',
        description: 'High quality widgets, shipped same-day.',
        keywords:    'widgets, gadgets, accessories',
        ogImage:     'https://example.com/og.png',
        priority:    1.0,
    ),
]);

Or attach metadata after the fact:

Router::get('/about', $aboutHandler);

Router::seo('/about', new SeoMeta(
    title:       'About us',
    description: 'A small team that loves great software.',
));

Sitemap & robots

These are registered automatically and cached:

  • GET /sitemap.xml — every static GET route, sorted, with changefreq derived from SeoMeta::priority.
  • GET /robots.txt — allow-all with the sitemap URL.
  • GET /health — JSON health check ({ "status": "ok" }) for load balancers.

If you register your own /sitemap.xml, /robots.txt, or /health, your version wins — the built-ins skip silently.

Keyword density helper

$report = Router::analyseKeywords($pageText, 10);

// $report[
//   'words' => 482,
//   'unique' => 271,
//   'density' => ['widgets' => 1.66, ...],
//   'suggested_keywords' => 'widgets, premium, ships, ...',
//   'suggested_description' => '...',
//   'warning' => false   // true if any word exceeds 3% density (stuffing)
// ]

Configuration (.env) #

A minimal .env is generated on first run with a randomly-generated APP_SECRET. You can override any of the following keys:

KeyTypeDefaultEffect
APP_SECRETstringrandom 64-hexHMAC for cache integrity, CSRF, and the cache-clear token. Required in prod.
DEBUG_MODEboolfalseEnables the bottom-of-page debug bar, full stack traces, the debug 404 page.
RESPONSE_CACHE_TTLint (seconds)300How long cacheable GET responses live on disk.
RATE_LIMIT_MAXint120Global max requests per IP per window.
RATE_LIMIT_WINDOWint (seconds)60Rate-limit window length.
CSRF_TOKEN_TTLint (seconds)7200Reserved (current implementation rotates per session).
TRUSTED_PROXIESCIDR listemptyWhen set, Router honours X-Forwarded-For & co.
ROUTER_DOMAIN_FOLDERSJSON / mapemptyMulti-domain folder map.
ROUTER_STATIC_FILE_SERVINGbooltrueToggle the domain-folder static asset bridge.
ROUTER_CORS_ORIGINSlistemptyCORS allowlist (alternative to Router::cors()).
ROUTER_CC_ROUTEstringemptySet to enable the optional cache-clear endpoint.

Quoting and comments

# A comment line.
DEBUG_MODE=false                  # inline comments are stripped on unquoted values
APP_SECRET="abc # not a comment"  # quoted values keep everything literal
ROUTER_CORS_ORIGINS=https://a.example.com,https://b.example.com

API reference #

Bootstrap

MethodDescription
Router::init()Idempotent. Loads .env, emits security headers, applies global rate-limit, installs exception handler.
Router::dispatch()Resolves the request and runs the matched handler. Call last in index.php.
Router::generateServerConfigs()Writes .htaccess, web.config, nginx.conf.example. CLI-only intent.

Route registration

MethodDescription
Router::get/post/put/patch/delete($pattern, $handler, $mw=[], $opts=[])Register a single-verb route.
Router::map(array $methods, …)Register the same handler for multiple verbs.
Router::any(…)Alias for map(['GET','POST','PUT','DELETE','PATCH'], …).
Router::group($prefix, $cb, $mw=[])Register routes under a shared prefix and middleware list. Nestable.
Router::use(callable) v2Register middleware that runs on every matched route.
Router::name(string) v2Name the most-recently-registered route.
Router::url(name, params=[]) v2Reverse-lookup a named route into a URL path.
Router::seo($pattern, SeoMeta)Attach SEO metadata to an existing route.
Router::setErrorHandler(int $code, callable)Override the renderer for 404/403/405/500.

Multi-domain

MethodDescription
Router::domains(array)Replace the host → folder map.
Router::domain(host, folder)Add a single mapping.
Router::currentDomainFolder()Returns the folder mapped for the current request host (or null).

Security helpers

MethodDescription
Router::csrf()Returns the current CSRF token; sets the XSRF-TOKEN cookie if missing.
Router::csrfMiddleware()Middleware that enforces CSRF on state-changing methods.
Router::cspNonce()Per-request CSP nonce. Use on inline <script>/<style>.
Router::cors(array $config)Configure CORS allowlist.
SecurityLayer::allowScriptSources([…])Add CSP script-src hosts before init().
SecurityLayer::allowStyleSources([…])Add CSP style-src hosts before init().
SecurityLayer::clientIp()Returns the real client IP, honouring trusted proxies.
SecurityLayer::csrfRotate()Force-rotate the CSRF token (e.g. after a successful login).

Cache

MethodDescription
Router::flush()Wipe the response cache.
Router::wipeCache(includeLogs=false)Wipe everything in .ncache/; logs preserved by default.
Router::cacheClearToken()HMAC token (5-min rotating window) for the optional /_cc endpoint.
CacheEngine::set/get/deleteProgrammatic key/value access (HMAC-guarded, TTL-based).

SEO

MethodDescription
Router::emitSeoHead()Echo the buffered SEO tags. Call inside <head>.
Router::analyseKeywords($text, $max=10)Compute keyword density and return suggested meta strings.
new SeoMeta(...)Immutable value object: title, description, keywords, ogTitle, ogImage, ogType, twitterCard, canonical, priority, lastmod.

Error handling #

Router renders styled error pages out of the box for 404, 403, 405, 429 and 500. Override any of them:

Router::setErrorHandler(404, function (int $code, string $message): string {
    ob_start();
    require __DIR__ . '/views/404.php';
    return ob_get_clean() ?: '';
});

Router::setErrorHandler(500, function (int $code, string $message): string {
    return '<h1>Sorry, something broke.</h1>';
});

Uncaught exceptions and fatal errors

  • Throwables thrown from a handler are caught and routed through the 500 renderer; the message is logged to .ncache/logs/error.log with stack trace.
  • Fatal errors (E_ERROR, E_PARSE, OOM) are caught by a shutdown hook so you never serve a White Screen of Death.
  • Output buffers are purged before rendering an error so partial content can’t leak.
  • In DEBUG_MODE, you get a dark-themed exception page with file, line, and trace.

Recipes #

JSON API with auth + rate limit

$jwtAuth = function ($p, $i): bool {
    $hdr = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
    if (!preg_match('/^Bearer\s+(.+)/', $hdr, $m)) {
        http_response_code(401);
        echo json_encode(['error' => 'auth required']);
        return false;
    }
    return true; // verify $m[1] in your app
};

Router::group('/api/v1', function (): void {
    Router::get('/me', function () {
        header('Content-Type: application/json');
        echo json_encode(['user' => 'demo']);
    });

    Router::post('/posts', function ($p, $i) {
        header('Content-Type: application/json');
        echo json_encode(['ok' => true, 'received' => $i['json']]);
    });
}, [$jwtAuth]);

// Tighter limit on auth endpoints:
Router::post('/api/v1/login', $loginHandler, [], [
    'rate' => ['max' => 5, 'window' => 60],
]);

Server-rendered HTML page with SEO + CSP nonce

Router::get('/blog/{slug}', function (array $p) {
    $post = $blogRepo->find($p['slug']);
    if ($post === null) { http_response_code(404); echo 'not found'; return; }

    Router::seo("/blog/{$p['slug']}", new SeoMeta(
        title:       $post->title,
        description: substr($post->excerpt, 0, 155),
        canonical:   "https://example.com/blog/{$p['slug']}",
        ogImage:     $post->coverImage,
        priority:    0.7,
    ));

    require __DIR__ . '/views/post.php';   // calls Router::emitSeoHead() inside <head>
}, [], ['cacheable' => true]);

Healthcheck for a load balancer

// Already registered automatically — but if you want a custom one:
Router::get('/healthz', function () {
    header('Content-Type: application/json');
    echo json_encode([
        'status'  => 'ok',
        'version' => trim(file_get_contents(__DIR__ . '/VERSION') ?: 'dev'),
        'php'     => PHP_VERSION,
        'time'    => date('c'),
    ]);
});

Login that rotates CSRF

Router::post('/login', function ($p, $i) {
    $email = $i['post']['email'] ?? '';
    $pass  = $i['post']['password'] ?? '';

    if (!$auth->verify($email, $pass)) {
        http_response_code(401);
        echo 'Invalid credentials';
        return;
    }

    session_regenerate_id(true);
    SecurityLayer::csrfRotate();   // new CSRF token after privilege change
    header('Location: /', true, 302);
}, [Router::csrfMiddleware()], ['rate' => ['max' => 5, 'window' => 60]]);

Serving a single-page app

// 1) Static asset directory served by your web server.
// 2) Everything else falls through to index.html for client-side routing.

Router::get('/{path*}', function () {
    header('Content-Type: text/html; charset=UTF-8');
    readfile(__DIR__ . '/dist/index.html');
});

Troubleshooting #

“Misconfigured: APP_SECRET must be set”

Production refuses to boot when APP_SECRET still equals the placeholder. Fix:

php -r "echo bin2hex(random_bytes(32)) . PHP_EOL;" >> .env
# then edit .env so the line reads:  APP_SECRET=<your-hex>

“Session cookies disappearing in iframes”

Router sets session cookies with SameSite=Lax. Browsers strip them in cross-site iframes by design. If you genuinely need cross-site embedding, set the cookie yourself with SameSite=None; Secure.

“My CSP nonce isn’t working”

You must call Router::cspNonce() after Router::init() and before any echo that emits inline scripts. Headers are flushed to the client on the first echo.

“My cached page is showing another user’s data”

It shouldn’t — Router skips cache reads/writes when Authorization or any session cookie is present. If you’re still seeing it, you probably set 'cacheable' => true on a route that conditionally personalises output without going through the session cookie. Either remove the flag, or set a custom cookie name and adjust the skip pattern.

“404 even though the route exists”

  • Trailing slashes: /about/ is normalised to /about; both match.
  • Sub-folder install: confirm SCRIPT_NAME is being stripped. Run with DEBUG_MODE=true and check the debug 404 page — it lists every registered pattern.
  • Two routes at the same trie level with different param names: one will silently overwrite the other’s param. Use the same name.

“Headers already sent” on session_start

Some shared hosts emit a BOM or whitespace before <?php in index.php. Save your file as UTF-8 without BOM, and don’t put any blank lines or output before require 'Router.php';.

Deploy checklist #

  1. Set DEBUG_MODE=false. Production never wants verbose error pages.
  2. Set a real APP_SECRET. 64 hex chars from random_bytes(32).
  3. Run Router::generateServerConfigs() once. Commit the resulting .htaccess / web.config to source control if you want repeatable deploys.
  4. Configure trusted proxies. Without it, the rate limiter rate-limits your CDN — not actual users.
  5. Make .ncache/ writable by the web user (chmod 0750). Keep it outside your public root or rely on the auto-generated rules to block direct access.
  6. Wire /health into your LB. Or replace it with a richer one that checks your DB.
  7. Disable error display in php.ini: display_errors=0, log_errors=1.
  8. Rotate APP_SECRET on a schedule. Doing so invalidates all current cache entries (re-built on next request) and CSRF tokens (users log in again).