Search by

brettparson / miniroute

BrettParson

A small attribute-based routing and middleware kernel for PHP applications.

Package info

src.brett-parson.com/git/miniroute.git

pkg:composer/brettparson/miniroute

Statistics

Installs: 3

Dependents: 0

Suggesters: 0

v0.2.0 2026-10-31 21:39 UTC

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-level group() middleware by path prefix.
  • Thin HTTP contracts — RequestInterface and ResponseInterface keep 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.