hoa/router
Hoa\Router is a standalone PHP routing component for mapping HTTP and CLI requests to handlers. It offers route patterns, variable constraints, HTTP method matching, and flexible callbacks, making it easy to build clean, maintainable routing layers for custom PHP applications.
hoa/router is a small, standalone routing component: it matches an incoming HTTP (or CLI) request against named route patterns and gives you back a Hoa\Router\Route object containing the matched callable and extracted parameters. It does not dispatch for you, nor does it provide controllers, middleware, or a PSR-7 bridge — you wire those yourself.
Install with Composer:
composer require hoa/router
Before writing code, look at four classes:
Hoa\Router\Router — abstract base, route registry, route(), unroute(), getRoutes().Hoa\Router\Http — HTTP request matching (get(), post(), any(), etc.).Hoa\Router\Route — matched route metadata: rule, methods, callable, substitutes, binds.Hoa\Router\Exception\NotFound — thrown when nothing matches.Minimal front-controller use case:
require __DIR__ . '/vendor/autoload.php';
$router = new Hoa\Router\Http();
$router->get('home', '/', function () {
echo 'Home';
});
$router->get('user', '/user/(?<id>\d+)', function ($id) {
echo 'User #', $id;
});
try {
$route = $router->route();
$callable = $route->getCallable();
// Substitutes are keyed by placeholder name; pass them positionally.
$callable(...array_values($route->getSubstitutes()));
} catch (Hoa\Router\Exception\NotFound $e) {
http_response_code(404);
echo 'Not found';
}
First practical use case: replace a hand-rolled switch ($_SERVER['REQUEST_URI']) in a small PHP app with named routes, then later add a router-aware dispatcher or integrate it into Hoa's MVC stack.
Named routes and lookup. Every route has an ID as its first argument. You can retrieve a route definition without matching the current request:
$router->get('blog.show', '/blog/(?<slug>[a-z0-9-]+)', $callable);
$route = $router->route('blog.show'); // no matching performed
Use named routes when you need to inspect or mutate a definition later. Remove one with $router->unroute('blog.show').
HTTP verbs. Use the verb-specific helpers for clarity:
$router->get('list', '/posts', $list);
$router->post('create','/posts', $create);
$router->put('update', '/posts/(?<id>\d+)', $update);
$router->delete('destroy', '/posts/(?<id>\d+)', $destroy);
$router->any('catchall', '/.*', $fallback);
any() matches all methods; ordering matters because the first matching rule wins.
Pattern placeholders. Placeholders use named PCRE groups:
$router->get('post', '/post/(?<id>\d+)/(?<slug>[^/]+)', function ($id, $slug) {
// ...
});
After matching, getSubstitutes() returns an associative array (['id' => '42', 'slug' => 'hello']). Values are strings; cast/validate them before use. Because the regex is your validation layer, keep patterns tight rather than relying on post-match checks.
Callables. The third argument is anything Hoa\Consistency\Xcallable understands: closures, invokable objects, [ClassName::class, 'method'], 'function_name', etc. Route::getCallable() returns the wrapped callable; invoke it yourself. A reusable dispatcher is usually cleaner than repeating invocation logic:
function dispatch(Hoa\Router\Router $router): void
{
try {
$route = $router->route();
$callable = $route->getCallable();
$callable(...array_values($route->getSubstitutes()));
} catch (Hoa\Router\Exception\NotFound $e) {
http_response_code(404);
}
}
Binds and route metadata. Route::setBinds() / getBinds() attach arbitrary data to a route definition (defaults, middleware hints, permission requirements). This is a lightweight extension point — read binds in your dispatcher before invoking the callable:
$router->get('admin', '/admin', $callable)->setBinds([
'middleware' => ['auth', 'csrf'],
]);
Integration with a front controller. Keep the router at the edge and push actual work into services/controllers. A typical bootstrap: create the router, register routes from a config file or route module, dispatch, catch NotFound, and let domain exceptions bubble up. If you use PSR-7, extract method and path from the request and feed the router through its request setters (see Gotchas) or adapt $_SERVER before constructing it.
CLI routing. Hoa\Router\Cli uses the same named-route concept but matches command-line arguments. Use it when you want a single routing abstraction across web and console entry points, though in practice most projects use a dedicated console framework instead.
Testing. Because matching depends on request globals, the usual test seam is to set request parts explicitly on the router before calling route():
$router = new Hoa\Router\Http();
$router->setMethod('GET');
$router->setPath('/user/42');
$router->setQuery('');
$route = $router->route();
If your installed version lacks those setters, manipulate $_SERVER/$_REQUEST in the test bootstrap or wrap router construction in a small factory.
route('name', $params) URL builder. This is the biggest day-to-day difference from Laravel/Symfony routers.any('/.*', ...) placed early will swallow everything./foo and /foo/ are different rules unless your pattern accounts for it. Keep patterns explicit and test both variants if users may type either.route() throws on miss. Always wrap the match call in try/catch for Hoa\Router\Exception\NotFound (check the exact exception class in the installed version). Uncaught, it becomes a 500 instead of a 404.(?<id>\d+) survived intact through all server/proxy layers.call_user_func_array() behaves differently on PHP 8 (named arguments). Use ...array_values($route->getSubstitutes()) or an explicit argument mapper to keep behavior consistent.Hoa\Router\Http reads from $_SERVER/$_REQUEST. This makes it awkward behind proxies, in subdirectory deployments, or in unit tests. Normalize REQUEST_URI, SCRIPT_NAME, and method override headers before construction; use the setters for tests.hoa:// stream protocol and Hoa\Protocol bootstrap. If you see path-resolution errors, ensure Composer's autoload/bootstrap has initialized Protocol, or avoid hoa:// paths entirely.setBinds() and subclassing Hoa\Router\Http / Hoa\Router\Router are the main hooks. If you need route groups, prefixes, middleware pipelines, or named URL generation, build them around the router rather than expecting them inside it.composer why hoa/router before adopting. Given the archive status and zero dependents, this is likely a legacy-integration choice rather than a greenfield one. If you are starting fresh, compare against a maintained alternative; if you are maintaining an existing Hoa-based app, isolate the router behind your own interface so it can be swapped later.How can I help you explore Laravel packages today?