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.
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
- 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. - 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. - You write the response with
echoorheader(). 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.phpfor 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
Router::init() is explicit
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();"
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();
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)
}
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 { }.
| Pattern | Matches | Captures |
|---|---|---|
/about | Exact path /about | — |
/users/{id} | Any single segment after /users/ | id |
/posts/{id:int} | Digits only | id |
/blog/{slug:slug} | Letters, digits, dashes, underscores | slug |
/items/{id:uuid} | UUID v1–v5 | id |
/mix/{x:[a-z]+} | Any custom regex | x |
/blog/{year?} | Both /blog and /blog/2024 | year (optional) |
/files/{path*} | Greedy: /files/a/b/c.txt too | path = a/b/c.txt |
Built-in constraint aliases
| Alias | Equivalent regex | Example match |
|---|---|---|
int | [0-9]+ | 42 |
alpha | [A-Za-z]+ | hello |
alnum | [A-Za-z0-9]+ | abc123 |
slug | [A-Za-z0-9_-]+ | my-first-post |
uuid | RFC-4122 hex pattern | 3f5b…0c91 |
Match precedence
When two routes could match the same URL, Router.php picks one in this order:
- Literal segments win over dynamic ones.
/users/mebeats/users/{id}. - Constraint match. If a dynamic segment has a constraint, it must match — otherwise traversal continues to the wildcard.
- Wildcard (
{path*}) matches any remaining path and is always last-resort.
/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>
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
- Global middleware (registered with
Router::use()), in registration order. - Group middleware, outermost group first.
- Route-specific middleware (3rd argument to the registration call).
- 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' }),
});
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
* with credentials
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-Cookieheaders are stripped from the cached response — no session bleed-over.- If the request carries any session cookie or
Authorizationheader, 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"
/_cc without a token
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
GETandHEADrequests. - Anything inside the mapped folder, except blocked extensions:
.php,.env,.log,.sql,.htaccess, etc. - Symlinks pointing outside the folder are refused (
realpathcontainment check). - Fingerprinted assets (
app.a3f9c2.js) getCache-Control: immutable; everything else gets a 1-hourmust-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, withchangefreqderived fromSeoMeta::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:
| Key | Type | Default | Effect |
|---|---|---|---|
APP_SECRET | string | random 64-hex | HMAC for cache integrity, CSRF, and the cache-clear token. Required in prod. |
DEBUG_MODE | bool | false | Enables the bottom-of-page debug bar, full stack traces, the debug 404 page. |
RESPONSE_CACHE_TTL | int (seconds) | 300 | How long cacheable GET responses live on disk. |
RATE_LIMIT_MAX | int | 120 | Global max requests per IP per window. |
RATE_LIMIT_WINDOW | int (seconds) | 60 | Rate-limit window length. |
CSRF_TOKEN_TTL | int (seconds) | 7200 | Reserved (current implementation rotates per session). |
TRUSTED_PROXIES | CIDR list | empty | When set, Router honours X-Forwarded-For & co. |
ROUTER_DOMAIN_FOLDERS | JSON / map | empty | Multi-domain folder map. |
ROUTER_STATIC_FILE_SERVING | bool | true | Toggle the domain-folder static asset bridge. |
ROUTER_CORS_ORIGINS | list | empty | CORS allowlist (alternative to Router::cors()). |
ROUTER_CC_ROUTE | string | empty | Set 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
| Method | Description |
|---|---|
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
| Method | Description |
|---|---|
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) v2 | Register middleware that runs on every matched route. |
Router::name(string) v2 | Name the most-recently-registered route. |
Router::url(name, params=[]) v2 | Reverse-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
| Method | Description |
|---|---|
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
| Method | Description |
|---|---|
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
| Method | Description |
|---|---|
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/delete | Programmatic key/value access (HMAC-guarded, TTL-based). |
SEO
| Method | Description |
|---|---|
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.logwith 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_NAMEis being stripped. Run withDEBUG_MODE=trueand 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 #
- Set
DEBUG_MODE=false. Production never wants verbose error pages. - Set a real
APP_SECRET. 64 hex chars fromrandom_bytes(32). - Run
Router::generateServerConfigs()once. Commit the resulting.htaccess/web.configto source control if you want repeatable deploys. - Configure trusted proxies. Without it, the rate limiter rate-limits your CDN — not actual users.
- 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. - Wire
/healthinto your LB. Or replace it with a richer one that checks your DB. - Disable error display in
php.ini:display_errors=0,log_errors=1. - Rotate
APP_SECRETon a schedule. Doing so invalidates all current cache entries (re-built on next request) and CSRF tokens (users log in again).