Alpha — v0.1.0. This module is in early testing. The API may change before a stable release. Feedback and bug reports are welcome.
A URL segment router for ProcessWire that replaces complex if/elseif chains with clean, expressive route definitions. Supports named parameters, type aliases, regex constraints, optional segments, wildcards, and HTTP method matching — all wired directly into ProcessWire's template system via page hooks.
The Router lives at SimpleWire\Router\Router and can be installed standalone or as part of the SimpleWire suite.
- Explicit HTTP Methods:
GET,POST,PUT,PATCH,DELETEwith cleanmethod:pathsyntax - Named Parameters: Extract URL segments into named variables passed to your handler
- Type Aliases: Built-in shortcuts for common patterns (
integer,slug,uuid,date, etc.) - Custom Regex Constraints: Inline regex for full control over segment matching
- Mixed Content Segments: Patterns like
v{major}orfile.{ext}extract embedded values - Optional Segments:
[{version}]matches with or without the segment - Wildcard Routes:
{*}captures all remaining segments into an array - Named and Simple Options:
(planet:earth|mars|jupiter)for fixed-value segments - Multi-method Registration: Register a route for multiple HTTP methods in one call
- Per-template Caching: Routes compiled to PHP cache files for production performance
- 404 Control: Choose between middleware mode (return null) or full takeover (throw 404)
- Custom 404 Handler: Define your own not-found response per template
Install SimpleRouter through ProcessWire Admin → Modules → Site → SimpleRouter.
The route(), router(), and simplerouter() global functions are available automatically after installation.
Prerequisite: URL segments must be enabled for any template where you use the router. Check the template settings: Admin → Setup → Templates → your template → URLs tab → Allow URL segments.
// Via the shorthand function (recommended)
$router = simplerouter();
// Via the global router() helper
$router = router();
// Via the ProcessWire API variable
$router = wire()->simplerouter;The simplest way to register a route from inside a template is the $page->route() hook. Pass a "method:path" string and a handler:
$page->route("get:users", $handler); // GET /users
$page->route("post:users", $handler); // POST /users
$page->route("put:users/{id}", $handler); // PUT /users/{id}
$page->route("delete:users/{id}", $handler); // DELETE /users/{id}If no method prefix is given, GET is assumed:
$page->route("hello", $handler); // Same as "get:hello"The route() function is equivalent and can be used anywhere in a template:
route("get:users", $handler);
route("post:users", $handler);
route("delete:users/{id}", $handler);It returns the Router instance for chaining:
route("get:users", $handlerA)->get("products", $handlerB);You can also call methods on the Router instance directly:
$router = simplerouter();
$router->get("users", $handler);
$router->post("users", $handler);
$router->put("users/{id}", $handler);
$router->patch("users/{id}", $handler);
$router->delete("users/{id}", $handler);
$router->any("users/{id}", $handler); // GET | POST | PUT | PATCH | DELETEadd() is the underlying method — it accepts pipe-separated methods for multi-method registration:
$router->add('GET|POST', 'contact', $handler);Exact match, no parameters:
route("get:hello/world", function() {
return "Hello World!";
});Capture any URL segment by wrapping the name in { }:
route("get:users/{id}", function($id) {
// GET /users/42 → $id = '42'
});
route("get:products/{category}/{slug}", function($category, $slug) {
// GET /products/electronics/laptop-pro
});Constrain parameters to a specific pattern using {name<alias>}:
route("get:users/{id<integer>}", $handler); // digits only
route("get:products/{slug<slug>}", $handler); // URL-safe string
route("get:resources/{uid<uuid>}", $handler); // UUID format
route("get:archive/{date<date>}", $handler); // YYYY-MM-DD
route("get:year/{y<year>}", $handler); // four-digit year| Alias | Matches | Example |
|---|---|---|
integer |
Positive integers | 1, 123, 9999 |
float |
Decimals with required dot | 3.14, 0.5, 123.456 |
number |
Any numeric value | 5, 3.14, 0.1 |
alpha |
Letters only | abc, Hello, xyz |
alphanumeric |
Letters and digits | user123, abc99 |
unicode |
International letters | José, Björk, 北京 |
slug |
URL-friendly strings | my-blog-post, product_name |
uuid |
Standard UUID | 550e8400-e29b-41d4-a716-446655440000 |
date |
ISO date YYYY-MM-DD | 2024-12-25 |
year |
Four-digit year | 2024, 1999, 2030 |
month |
Month 01–12 | 01, 06, 12 |
day |
Day 01–31 | 01, 15, 31 |
An unrecognized alias name will never match — a warning to catch typos.
Use inline regex with {name:pattern} for full control:
route("get:users/{id:[0-9]+}", $handler); // digits only
route("get:posts/{slug:[a-z0-9\-]+}", $handler); // lowercase alphanumeric + hyphens
route("get:version/{v:v[0-9]+}", $handler); // v1, v2, v10Embed a parameter inside a literal segment using prefix{name}suffix:
route("get:hello/great-{planet}", function($planet) {
// GET /hello/great-mars → $planet = 'mars'
});
route("get:files/{name}.{ext}", function($name, $ext) {
// GET /files/report.pdf → $name = 'report', $ext = 'pdf'
});
route("get:api/v{major}", function($major) {
// GET /api/v2 → $major = '2'
});Wrap a segment in [ ] to make it optional:
route("get:api/[{version}]", function($version = 'v1') {
// Matches both /api and /api/v2
});Multiple optional segments are allowed:
route("get:archive/[{year}]/[{month}]", function($year = null, $month = null) {
// Matches /archive, /archive/2024, /archive/2024/06
});{*} captures all remaining URL segments into a $tail array:
route("get:files/{*}", function($tail) {
// GET /files/docs/2024/report.pdf
// $tail = ['docs', '2024', 'report.pdf']
$path = implode('/', $tail);
return serveFile($path);
});The wildcard must be the last segment in the pattern.
Match one of a fixed set of values and capture it by name:
route("get:hello/(planet:earth|mars|jupiter)", function($planet) {
// GET /hello/mars → $planet = 'mars'
// GET /hello/venus → no match
});Match one of a fixed set without a named capture:
route("get:hello/(earth|mars|jupiter)", function($match) {
// $match is the matched value, passed positionally
});Call $page->dispatchRoutes() after all route definitions. It runs the router and returns the handler's return value, or null if no route matched:
$result = $page->dispatchRoutes();
if ($result !== null) {
echo $result;
} else {
// No route matched — fall through to normal template rendering
}You can also call dispatch() directly on the Router instance:
$result = simplerouter()->dispatch();The router returns null for unmatched requests. Your template decides what to do:
$result = $page->dispatchRoutes();
if ($result !== null) {
echo $result;
} else {
// Render the page normally, or throw 404, or do anything else
}This is the best mode when mixing routed and non-routed content on the same template.
The router automatically throws Wire404Exception for unmatched requests. Useful for pure API templates where every request must match a defined route:
// All routes defined above
$result = $page->dispatchRoutes(); // throws Wire404 if nothing matched
echo $result;Define a custom response for unmatched routes. When handle404 = true, this fires instead of throwing an exception:
simplerouter()->setNotFoundHandler(function() {
http_response_code(404);
header('Content-Type: application/json');
return json_encode(['error' => 'Route not found']);
});<?php
// /site/templates/products.php
namespace ProcessWire;
$page->route("get:detail/{id<integer>}", function($id) {
$product = wire()->pages->get($id);
if (!$product->id) throw new Wire404Exception();
return $product->render();
});
$result = $page->dispatchRoutes();
if ($result !== null) {
echo $result;
} else {
// Default: render the page normally
echo "<h1>{$page->title}</h1>";
}<?php
// /site/templates/api.php
namespace ProcessWire;
simplerouter()->setNotFoundHandler(function() {
http_response_code(404);
header('Content-Type: application/json');
return json_encode(['error' => 'Endpoint not found']);
});
$page->route("get:products", function() {
$pages = wire()->pages->find("template=product, limit=50");
$data = [];
foreach ($pages as $p) {
$data[] = ['id' => $p->id, 'title' => $p->title, 'price' => $p->price];
}
header('Content-Type: application/json');
return json_encode($data);
});
$page->route("get:products/{id<integer>}", function($id) {
$product = wire()->pages->get($id);
if (!$product->id) {
http_response_code(404);
header('Content-Type: application/json');
return json_encode(['error' => 'Product not found']);
}
header('Content-Type: application/json');
return json_encode(['id' => $product->id, 'title' => $product->title]);
});
$page->route("post:products", function() {
$body = json_decode(file_get_contents('php://input'), true);
// ... create product
http_response_code(201);
header('Content-Type: application/json');
return json_encode(['success' => true, 'id' => $newPage->id]);
});
// handle404 = true in module config, or:
$result = $page->dispatchRoutes();
if ($result === null) throw new Wire404Exception();
echo $result;Navigate to Admin → Modules → Site → SimpleRouter → Configure.
| Setting | Default | Description |
|---|---|---|
| Enable Router Cache | true |
Compile route patterns to PHP cache files |
| Cache TTL (seconds) | 3600 |
How long cache files are considered valid |
| Router handles 404 | false |
When checked: unmatched routes throw Wire404Exception |
Routes are cached per template under the ProcessWire cache directory:
/site/assets/cache/SimpleWire/Router/product.cache.php
/site/assets/cache/SimpleWire/Router/api.cache.php
/site/assets/cache/SimpleWire/Router/blog.cache.php
Cache files are verified with a SHA1 hash file alongside them. A mismatch or expired TTL causes the cache to be rebuilt automatically on the next request.
<?php
// /site/templates/products.php
namespace ProcessWire;
// Category listing
$page->route("get:category/{name<slug>}", function($name) {
$products = wire()->pages->find("template=product, category.name=$name, limit=24");
if (!$products->count()) throw new Wire404Exception();
return wire()->files->render(wire()->config->paths->templates . 'partials/product-list.php', [
'products' => $products,
'category' => $name,
]);
});
// Product detail by integer ID
$page->route("get:detail/{id<integer>}", function($id) {
$product = wire()->pages->get("id=$id, template=product");
if (!$product->id) throw new Wire404Exception();
return wire()->files->render(wire()->config->paths->templates . 'partials/product-detail.php', [
'product' => $product,
]);
});
// Product search via POST
$page->route("post:search", function() {
$q = wire()->sanitizer->text(wire()->input->post('q'));
$results = wire()->pages->find("template=product, title~=$q, limit=20");
header('Content-Type: application/json');
$data = [];
foreach ($results as $p) {
$data[] = ['id' => $p->id, 'title' => $p->title, 'url' => $p->url];
}
return json_encode(['results' => $data, 'count' => count($data)]);
});
$result = $page->dispatchRoutes();
if ($result !== null) {
echo $result;
} else {
// Default product index
echo "<h1>{$page->title}</h1>";
echo "<div>{$page->body}</div>";
}<?php
// /site/templates/api.php
namespace ProcessWire;
simplerouter()->setNotFoundHandler(function() {
http_response_code(404);
header('Content-Type: application/json');
return json_encode(['error' => 'Endpoint not found']);
});
// GET /api/users
$page->route("get:users", function() {
$users = wire()->users->find("roles=member, limit=50");
$data = [];
foreach ($users as $u) {
$data[] = ['id' => $u->id, 'name' => $u->name, 'email' => $u->email];
}
header('Content-Type: application/json');
return json_encode($data);
});
// GET /api/users/42
$page->route("get:users/{id<integer>}", function($id) {
$user = wire()->users->get((int)$id);
if (!$user->id) {
http_response_code(404);
header('Content-Type: application/json');
return json_encode(['error' => 'User not found']);
}
header('Content-Type: application/json');
return json_encode(['id' => $user->id, 'name' => $user->name, 'email' => $user->email]);
});
// POST /api/users (JSON body: {"name":"...", "email":"..."})
$page->route("post:users", function() {
$san = wire()->sanitizer;
$body = json_decode(file_get_contents('php://input'), true) ?? [];
$name = $san->pageName($body['name'] ?? '');
$email = $san->email($body['email'] ?? '');
if (!$name || !$email) {
http_response_code(422);
header('Content-Type: application/json');
return json_encode(['error' => 'name and email are required']);
}
$user = new User();
$user->name = $name;
$user->email = $email;
$user->addRole('member');
$user->save();
http_response_code(201);
header('Content-Type: application/json');
return json_encode(['id' => $user->id, 'name' => $user->name]);
});
// DELETE /api/users/42
$page->route("delete:users/{id<integer>}", function($id) {
$user = wire()->users->get((int)$id);
if (!$user->id) {
http_response_code(404);
header('Content-Type: application/json');
return json_encode(['error' => 'User not found']);
}
wire()->users->delete($user);
header('Content-Type: application/json');
return json_encode(['success' => true]);
});
$result = $page->dispatchRoutes();
if ($result === null) throw new Wire404Exception();
echo $result;<?php
// /site/templates/blog.php
namespace ProcessWire;
// All posts, or filtered by year, or by year+month
$page->route("get:archive/[{year<year>}]/[{month<month>}]", function($year = null, $month = null) {
$selector = 'template=post, limit=20, sort=-date';
if ($year) $selector .= ", date>={$year}-01-01, date<=" . ($year + 1) . "-01-01";
if ($month) $selector .= ", date>={$year}-{$month}-01";
$posts = wire()->pages->find($selector);
header('Content-Type: application/json');
$data = [];
foreach ($posts as $p) {
$data[] = ['id' => $p->id, 'title' => $p->title, 'date' => $p->date];
}
return json_encode($data);
});
// Post by slug
$page->route("get:{slug<slug>}", function($slug) {
$post = wire()->pages->get("template=post, name=$slug");
if (!$post->id) throw new Wire404Exception();
return $post->render();
});
$result = $page->dispatchRoutes();
if ($result !== null) {
echo $result;
} else {
echo "<h1>{$page->title}</h1>";
}<?php
// /site/templates/files.php
namespace ProcessWire;
$page->route("get:download/{*}", function($tail) {
// GET /download/docs/2024/annual-report.pdf
// $tail = ['docs', '2024', 'annual-report.pdf']
$relative = implode('/', array_map('rawurldecode', $tail));
$basePath = wire()->config->paths->files;
$fullPath = realpath($basePath . $relative);
// Prevent directory traversal
if (!$fullPath || !str_starts_with($fullPath, $basePath)) {
http_response_code(403);
return 'Forbidden';
}
if (!file_exists($fullPath)) {
http_response_code(404);
return 'File not found';
}
header('Content-Type: ' . mime_content_type($fullPath));
header('Content-Disposition: attachment; filename="' . basename($fullPath) . '"');
return file_get_contents($fullPath);
});
echo $page->dispatchRoutes();| Method | Returns | Description |
|---|---|---|
get(string $path, callable $handler) |
self |
Register a GET route |
post(string $path, callable $handler) |
self |
Register a POST route |
put(string $path, callable $handler) |
self |
Register a PUT route |
patch(string $path, callable $handler) |
self |
Register a PATCH route |
delete(string $path, callable $handler) |
self |
Register a DELETE route |
any(string $path, callable $handler) |
self |
Register for all HTTP methods |
add(string $methods, string $path, callable $handler) |
self |
Register for pipe-separated methods ('GET|POST') |
setNotFoundHandler(callable $handler) |
self |
Set custom 404 handler |
dispatch() |
mixed|null |
Run the router — returns handler result or null |
| Hook | Description |
|---|---|
$page->route(string $definition, callable $handler) |
Register a route. $definition is "method:path" or just "path" (defaults to GET) |
$page->dispatchRoutes() |
Run the router and return the result (or null) |
| Function | Returns | Description |
|---|---|---|
simplerouter() |
Router |
Get the Router instance |
router() |
Router |
Alias for simplerouter() |
route(string $definition, callable $handler) |
Router |
Register a route and return the Router (chainable) |
// Good
route("get:users", $handlerA);
route("get:users/{id}", $handlerB);
$result = $page->dispatchRoutes();
// Bad — route defined after dispatch, never matched
$result = $page->dispatchRoutes();
route("get:users", $handlerA);Route parameters are raw URL segments. Always sanitize before using in queries or output:
route("get:search/{q}", function($q) {
$query = wire()->sanitizer->text(urldecode($q));
$results = wire()->pages->find("template=post, title~=$query");
// ...
});Type aliases prevent unintended matches and make intent clear:
// Good — only matches integers
route("get:users/{id<integer>}", $handler);
// Risky — matches any string, including /users/admin
route("get:users/{id}", $handler);Inside a closure handler, use wire() to access ProcessWire:
route("get:posts/{slug<slug>}", function($slug) {
$page = wire()->pages->get("template=post, name=$slug");
$san = wire()->sanitizer;
$input = wire()->input;
// ...
});- handle404 = false (default): Mixed templates where some URL segments go to the router and others render the page normally.
- handle404 = true: Pure API or fully route-based templates where every request must match a route.
Route pattern compilation happens once per template and is reused across requests. The performance gain is significant on templates with many routes.
- Confirm that
dispatchRoutes()is called after all route definitions - Verify URL segments are enabled for the template (Admin → Setup → Templates → your template → URLs tab)
- Test with a simple literal route first:
route("get:test", function() { return "ok"; }) - Check the actual URL path: segments start after the page URL, not from the domain root
- Check for typos in the pattern —
{id<integr>}(missing a letter) would silently never match - Ensure the HTTP method matches — a form that POSTs won't hit a
get:route - Try logging the active segments:
wire()->log->message(implode('/', wire()->input->urlSegments()))
The wildcard parameter is always named $tail — the variable in your handler must use that name:
// Correct
route("get:files/{*}", function($tail) { ... });
// Won't receive the value in PHP 8+
route("get:files/{*}", function($segments) { ... });- Verify the syntax:
{id<integer>}— angle brackets, not parentheses or colons - An unknown alias name produces a pattern that never matches — check spelling against the alias table
// Log current state for a request
wire()->log->save('router-debug', implode('/', wire()->input->urlSegments()));
wire()->log->save('router-debug', wire()->input->requestMethod());
// Inspect the router instance
$router = simplerouter();This module is released under the MIT License.