brettparson / miniroute
A small attribute-based routing and middleware kernel for PHP applications.
Requires
- php: >=8.2
Requires (Dev)
- phpunit/phpunit: ^10.5 || ^11.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is not auto-updated.
Last update: 2026-11-14 21:55:36 UTC
README
A small attribute-based routing and middleware kernel for PHP applications.
miniroute is the tiny piece of routing machinery extracted from brett-parson.com. It provides attribute-declared routes, deterministic route matching, and an onion-style middleware pipeline — without being a framework.
Requirements
- PHP >= 8.2
Install
Via Composer (Packagist)
composer require brettparson/miniroute:^0.2
The package is published on Packagist; the canonical source is the cgit repository.
Upgrading from 0.1 requires changing the Composer constraint to ^0.2.
Review the v0.2.0 compatibility notes,
especially path validation, HEAD/405 handling, parameter replacement and lazy
dependency resolution.
Via a local path repository (development)
{
"repositories": [
{ "type": "path", "url": "../miniroute", "options": { "symlink": true } }
],
"require": { "brettparson/miniroute": "@dev" }
}
Usage
use BrettParson\MiniRoute\Attribute\Get;
use BrettParson\MiniRoute\Attribute\Middleware;
use BrettParson\MiniRoute\Attribute\Post;
use BrettParson\MiniRoute\Http\RequestInterface;
use BrettParson\MiniRoute\Http\ResponseInterface;
use BrettParson\MiniRoute\Routing\Router;
final class NoteController
{
#[Get('/notes')]
public function index(RequestInterface $request): ResponseInterface
{
// ...
}
#[Post('/notes')]
#[Middleware(SomeMiddleware::class)]
public function store(RequestInterface $request): ResponseInterface
{
// ...
}
}
The router needs a controller/middleware resolver — an app-owned seam for building objects:
$router = new Router($resolver);
$router->group('/admin', [RequireAdmin::class, RequireCsrf::class]);
$router->registerControllers([
HomeController::class,
NoteController::class,
]);
$response = $router->dispatch($request);
$response->send();
What it does
- Route attributes —
#[Get],#[Post],#[Put],#[Patch],#[Delete]declare routes directly on controller methods. - Deterministic matching — literal segments beat
{parameter}segments, so registration order never matters. - Middleware —
#[Middleware(...)]on methods or classes, plus router-levelgroup()middleware by path prefix. - Thin HTTP contracts —
RequestInterfaceandResponseInterfacekeep the kernel decoupled from any one application's HTTP objects.
Paths and registration
Declare paths beginning with /. Requests use the same path form, optionally
with a query string; MiniRoute strips the query for matching. Applications must
extract paths from full URLs. Fragments, relative paths, repeated leading/interior
slashes, raw whitespace/control bytes, backslashes, and invalid percent escapes
are rejected. Invalid requests follow the not-found flow, never the root handler.
An empty request path means /; an empty declaration is invalid.
Trailing slashes normalize on both declarations and requests (/notes/ becomes
/notes). Percent escapes and dot segments stay literal; captured values are not
decoded. Matching remains case-sensitive. RouteMatcher itself matches exact
strings; request validation and normalization happen at Router dispatch.
Both controller registration methods are atomic for the route table. If any
controller in a batch fails, the table stays as it was before the call. Equivalent
same-method patterns such as /notes/{id} and /notes/{slug} are rejected,
including trailing-slash variants. Different methods and literal/parameter
overlaps remain supported. See the path and registration contract.
Missing paths and unsupported methods
If no route accepts the requested method but its path matches declarations,
dispatch() throws MethodNotAllowedException. Its allowed() method returns
uppercase, unique methods in alphabetical order, across all matching declarations
(including literal/parameter overlaps). A normal method-and-path match always wins
before this fallback. No controller or route middleware is resolved on the failure.
The exception extends RouteNotFoundException; catch it before the parent when
rendering HTTP responses. setNotFoundHandler() is for missing/invalid paths and
is not invoked for method mismatches. The application owns response creation:
use BrettParson\MiniRoute\Routing\MethodNotAllowedException;
use BrettParson\MiniRoute\Routing\RouteNotFoundException;
// AppResponse stands for your application's ResponseInterface implementation.
try {
$response = $router->dispatch($request);
} catch (MethodNotAllowedException $error) {
$response = (new AppResponse(405, 'Method not allowed'))
->withHeader('Allow', implode(', ', $error->allowed()));
} catch (RouteNotFoundException $error) {
$response = new AppResponse(404, 'Not found');
}
No exception code is used as an HTTP status and the router emits no headers itself.
Existing broad catches of RouteNotFoundException also catch method mismatches;
update application adapters when separate 405 rendering is wanted. allowed()
includes HEAD whenever GET matches. OPTIONS has no implicit handler and is only
advertised when explicitly declared through a custom route attribute.
HEAD requests
The router first searches all explicit HEAD declarations (custom attributes implementing
RouteAttribute are supported). If none matches, it searches GET declarations in the
usual order, with literal segments preferred over parameters. An explicit parameterized
HEAD route therefore wins even over a literal GET route. HEAD-only routes do not imply GET.
Fallback executes the selected GET route's complete middleware stack, including groups
filtered to GET. HEAD-filtered groups apply to explicit HEAD declarations only. This keeps
inspect() consistent with execution: it reports registered declarations, without synthetic
HEAD entries. Middleware and controllers still receive the original request, including its
HEAD method and path; parameter injection works as usual.
The router returns the response unchanged, including its body. Your application's HTTP
sender must omit the body for every HEAD response, including errors and middleware
short-circuits. Check the original request method when sending, for example
if (strtoupper($request->method()) !== 'HEAD') { echo $response->body(); }.
The application also owns header emission; HEAD routing does not add or remove headers.
Route parameters
Every successful dispatch replaces the entire route-parameter map before middleware
and controller execution. Literal routes clear it. Request adapters must implement
withParams() as immutable replacement, including for an empty array, rather than
merging with existing parameters. Other request data stays unchanged.
Parameters occupy a whole segment, such as /notes/{id2}. Names must be unique
within the route and match [A-Za-z_][A-Za-z0-9_]*; names are case-sensitive.
Malformed braces, trailing text in a parameter segment, and duplicate names throw
RouteRegistrationException. Captures remain strings and are not URL-decoded.
Matching uses numbered regex captures mapped to RouteDef::parameterNames, the
validated names in path order. Parameter names are not subject to the regex
engine's named-group length limit; ordinary whole-pattern engine limits still
apply. No parameter constraints or optional-segment syntax is introduced.
Inspect routes and validate groups
Groups select declared literal prefixes. /admin selects /admin/{id},
not /{section}/dashboard, even when the requested section is admin. Braces
in a group prefix throw RouteRegistrationException immediately. Attach
parameter-aware middleware explicitly to a generic route instead.
After registering controllers and groups:
$report = $router->inspect();
// Routes, handlers, and effective middleware in execution order, with origins.
$routes = $report->routes();
$diagnostics = $report->diagnostics();
// Optional application-owned logging: level, message, structured context.
$report->log(static function (string $level, string $message, array $context): void {
error_log(json_encode(compact('level', 'message', 'context'), JSON_THROW_ON_ERROR));
});
// Optional strict startup/CI validation: warnings become exceptions.
$report->throwOnErrors(strict: true);
A possible generic-route overlap and a group selecting no routes produce warnings.
throwOnErrors() without strict mode permits warnings; malformed prefixes have
already failed during group(). Strict failures throw RouteInspectionException;
its readonly $report property contains the inventory and diagnostics. Catch it
in application startup/tooling if you want custom presentation.
Each route record contains method, path, controllerClass, controllerMethod,
and middleware. Middleware entries contain class and source (group,
class, or method; route is the fallback for definitions without provenance).
Group entries also include group (zero-based registration index) and prefix.
Middleware order is outermost first and preserves duplicates. Diagnostics include
level, code, message, group, and prefix, plus relevant route or filter data.
Codes are group_possible_overlap and group_selects_nothing.
Inspection resolves no application objects and emits nothing until log() is
called. Logger failures propagate. It describes configured stacks, not which
layers executed after a short-circuit. Obtain a fresh report after configuration
changes. This API does not wrap runtime handler exceptions or render HTTP errors;
whole-request logging/error handling can wrap dispatch() with MiddlewarePipeline.
See the grouping decision.
What it is not
No container, no template engine, no ORM, no auth, no session handling, no configuration loader. Those stay application concerns.
Tests
composer install
composer check # lint + unit tests
composer verify -- full # strict local gate with revision, logs and JUnit
composer fuzz -- --seed playground --cases 1000 # bounded properties + replay artifacts
composer bench # registration/dispatch scaling benchmark
Or, without Composer, drop a phpunit.phar into the repo root and run
php phpunit.phar.
License
MIT — see LICENSE.
Controller method visibility
Route methods must be public. Registration throws RouteRegistrationException
naming the controller and method when a reflected private or protected method
has a route attribute, including custom RouteAttribute implementations.
Non-public helpers without route attributes remain valid. Inherited protected
routes are rejected; inherited public routes retain their method metadata.
Validation follows PHP's reflected method set: hidden ancestor methods are not
scanned separately. A failed controller or batch registration leaves the existing
route table unchanged.
Dependency resolution timing
Dispatch resolves each middleware occurrence when execution reaches that layer, then resolves the controller when execution reaches the terminal handler. A denial response skips resolution of the controller and all remaining middleware. Parameters are replaced before the outermost middleware runs.
Each successfully resolved instance is reused within that dispatch. Calling $next
more than once executes downstream handlers again with those same instances; each
new dispatch starts fresh resolution. Duplicate middleware entries remain separate
occurrences. The application resolver may itself return shared instances.
Resolver exceptions and invalid middleware errors propagate from the reached layer,
so outer middleware can catch them or run finally cleanup. Failed resolution is
not cached: retrying $next retries resolution. The standalone MiddlewarePipeline
still composes already-created instances.
See local verification for prerequisites, fast/full profiles, report interpretation and the behavior contract inventory.
Explore deterministic fuzzing and replay with a custom seed,
or run the committed corpus with php tools/fuzz.php --cases 0.