meraki / http-router
Class-driven HTTP router for PHP 8.4+ — the action class name encodes intent (Collection / Item / Action), with no inflection, no route files, and pluggable casters for typed parameters.
Requires
- php: ^8.4
- psr/log: ^3.0
- willdurand/negotiation: ^3.1
Requires (Dev)
- captainhook/captainhook: ^5.29
- friendsofphp/php-cs-fixer: ^3.95
- laminas/laminas-diactoros: ^3.8
- laminas/laminas-httphandlerrunner: ^2.13
- marcocesarato/php-conventional-changelog: ^1.17
- phpbench/phpbench: ^1.6
- phpunit/phpunit: ^13.1
- psalm/plugin-phpunit: ^0.20
- ramsey/conventional-commits: ^1.7
- ramsey/uuid: ^4.9
- spatie/phpunit-watcher: ^1.24
- symfony/console: ^7.3
- symfony/yaml: ^7.3
- vimeo/psalm: ^7.x-dev
This package is auto-updated.
Last update: 2026-05-30 03:49:48 UTC
README
Maps HTTP requests to HTTP responses in PHP 8.4+.
Features
Routing model
- Root path
/mapping (configurable sub-namespace, defaultHome) - RESTful route types from the class name alone — no inflection, no route files:
GetAllAction(Collection),GetOneAction(Item),GetAction(Action / verb / static) - Nested resources with parameter inheritance from the parent route (e.g.
/states/{state}/suburbs/{suburb}) - Disambiguation of static (
Action) and RESTful routes at the same namespace —/users/createresolves toUsers\Create\GetAction, notUsers\GetOneAction('create') - Variadic routing (trailing parameters absorbed by
...$args) - Configurable action prefix, suffix, and singular/plural indicators via
Router\Config
HTTP methods
GET,POST,PUT,PATCH,DELETEmapped from method-prefixed class namesHEADauto-derived fromGET(body stripped at the SAPI layer)OPTIONSauto-synthesised (204+Allow:) listing every method available at the URL- Additional methods (WebDAV, etc.) via
Config::withAdditionalMethods('propfind', …) - Allowed-methods list returned on
405; method discovery is poisoning-proof (one misconfigured method doesn't hide the others)
Parameters (via the Caster system)
- Required, optional, and variadic parameters
- Built-in types:
string(universal — never fails),int,float,array(CSV), enums (backed by value; pure enums by case name, case-insensitive),UuidInterface(requiresramsey/uuid) - Union types (e.g.
int|string) — first matching type wins; the universalstringcaster takes precedence in a union - Value-object parameters (constructor-driven, arbitrary nesting —
Date(Year, Month, Day)consumes one segment per leaf) — opt-in viaConfig::withCaster(new ValueObjectCaster()) - Custom parameter types: register your own
CasterviaConfig::withCaster()
Status semantics
400URL too short for a required parameter (or a value object's required ctor params)404no route of that shape (including too-many segments)405method not allowed, withAllow:populated422route matched but a value couldn't cast to its parameter's type204auto-synthesisedOPTIONSresponse- Misconfigured handler trees throw (
UnallowedVariadicParameter,SignatureMismatch) instead of silently mis-routing — surface the developer error
Extensibility
- Custom PSR-3 logger via
Config::withLogger() Configis immutable; all extension points are pluggableCasterimplementations orwith*()config methods
Roadmap
What's coming after alpha, grouped roughly by theme:
Content negotiation (planned for the negotiation milestone)
- Pluggable media-type / language negotiator
406 Not Acceptablewith the accepted types in the response
Observability
- Built-in logging hooks at route-resolution boundaries (the custom logger already plugs in via
Config::withLogger(); the router doesn't emit log events yet)
Performance
- Optional route/reflection cache (route resolution today does
class_existsper candidate + reflection per matched handler — a request-scoped cache is the obvious win)
Routing extras
- Prevent alternative root-path aliasing (so
/and/homearen't both routable toHome\GetAction) - Ignore non-URL handler parameters — e.g. let a handler typed
__invoke(Request $request, int $id)skip$requestfor routing purposes and only consume$idfrom the URL
Tooling
- Reverse routing (build a URL from a handler class + arguments)
- Route dumper (enumerate every routable URL from the handler tree)
- Route-handler generator (scaffold a handler class for a given URL shape)
Runtime
- Concurrency support for Swoole (the router is stateless — likely already works, but needs verification + a documented setup)
Installation
composer install meraki/http-router
Usage
Instantiate the router, pass the request method and request target, then handle the result.
<?php require_once __DIR__ . '/../vendor/autoload.php'; use Meraki\Http\Router; use Meraki\Http\Router\Config; use Meraki\Http\Router\Exception\SignatureMismatch; use Meraki\Http\Router\Exception\UnallowedVariadicParameter; use Laminas\Diactoros\ServerRequestFactory; $router = new Router(Config::create('Project\\Http\\')); $request = ServerRequestFactory::fromGlobals(); try { $result = $router->route($request->getMethod(), $request->getRequestTarget()); } catch (UnallowedVariadicParameter | SignatureMismatch $e) { // Misconfigured handler tree (a variadic parent that shadows a child, or a // nested RESTful handler with no addressed parent). These are developer // errors — fix the handler classes, don't ignore. } switch ($result->status) { case 200: $route = $result->route; // $route->requestHandler is the matched class-string, // $route->invokeMethod the method to call (default '__invoke'), // $route->arguments the bound parameters in order. $handler = new ($route->requestHandler)(); $response = $handler->{$route->invokeMethod}(...$route->arguments); break; case 204: // Auto-synthesised OPTIONS: $result->allowedMethods lists every method // available at this URL — emit them in the Allow header. break; case 400: // URL is too short for a required parameter (or a value object's // required constructor segments are missing). break; case 404: // No route of that shape. break; case 405: // Handlers exist at this URL but not for the requested method — // $result->allowedMethods carries the Allow list. break; case 422: // The route was matched but a segment couldn't cast to its parameter's // type (e.g. "abc" -> int). break; }
route() deliberately takes the method and request target as strings, not a request object, so the router doesn't depend on any particular HTTP framework. To support content negotiation later, a small request-context object may be added.
See examples/ for a runnable demo of every routing behaviour; the examples README catalogues each URL with what it demonstrates.
Intentions and design decisions
-
RESTful child resources require a parent handler for the same HTTP method, whose signature the child extends.
For example, the following HTTP request:
POST /artists/123/songs/456will only work if the following two classes exist:
$parentResource = Project\Http\Artists\PostOneAction::class; $childResource = Project\Http\Artists\Songs\PostOneAction::class;
The
$parentResourceis never instantiated during routing, but it must exist, and$childResourcemust 'extend' its method signature. If$parentResourceispublic function __invoke(int $artist), then$childResourcemust bepublic function __invoke(int $artist, int $song)orpublic function __invoke(int $artist, int $song, ...$args). (This applies to RESTful types only —Actionroutes are standalone; see decision 5.) -
No PSR-7 dependency. The library does not rely on PSR-7 for request/response objects, for the greatest compatibility between HTTP implementations.
-
Convention over configuration. The URL structure and HTTP method imply the handler's fully-qualified class name and method signature. There are no route files and no attributes on handlers — both of which are more error-prone and less performant.
-
The class name encodes intent — there is no inflection, pluralization, or URL guessing. The class-name suffix alone determines the route type (
GetAllAction→ Collection,GetOneAction→ Item,GetAction→ Action). A compound resource likeRegisteredBusinessesneeds no special inflection rule. Because the mapping is mechanical and total, handler names are deterministic and reversible (a prerequisite for future reverse-routing). -
Action(static) routes are standalone and take priority. AnActionroute binds only the segments that follow its own namespace; it never inherits a parent's arguments, and it wins over a RESTful handler at the same namespace. This makes its URL fully deterministic. Reach for it for verbs or fixed overrides — e.g. a static/users/createliving alongside a dynamic/users/{id}, or a vanity/users/{username}. (Seeexamples/Http/Music/TrackInfo/GetAction.phpfor the ambiguity that mixing static and RESTful routing can create.) -
stringis the universal segment type; only narrowing types can fail. Astringparameter accepts any URL segment and never produces a422. Only narrowing types (int,float, enums, value-objects) can reject a value as unprocessable. -
HTTP methods are handled deliberately.
CONNECTandTRACEare never application methods (always405).OPTIONSis auto-synthesised (204) advertising the allowed methods, andHEADis derived fromGET. The supported-method set is configurable viaConfig::withAdditionalMethods()for WebDAV (PROPFIND,MKCOL, …) or other extensions. -
Method discovery is "poisoning-proof." When building the
Allowlist for a405/OPTIONS, a single misconfigured handler for one method does not hide the other valid methods available at the same URL. -
Misconfigurations surface as exceptions, not silent mis-routes. A variadic parent that would permanently shadow a child route throws
UnallowedVariadicParameter; an unreachable nested RESTful handler throwsSignatureMismatch. These are developer errors in the handler tree, so they are raised rather than quietly returning a404. -
Status codes are precise.
400= the URL is too short for a required parameter (structurally malformed);404= no route of that shape (including too many segments);422= a route matched but a value can't cast to its parameter's type. -
URLs are matched case-insensitively. The whole request target is lower-cased before matching, so
/Users/1and/users/1resolve to the same handler. The lower-cased segment is also what reaches the caster, so a pure (unbacked) enum's case-name match (e.g.Month::August) is intentionally case-insensitive. Use lower-case for string-backed enum values and URL-friendly value-object inputs; if you need original case preserved in a bound value, type the parameter asstring(universal) rather than relying on case for matching.
How routing works
Take $router->route('GET', '/states/qld/suburbs/emerald'):
-
Normalise. The method is lower-cased and the request target is parsed and split into segments —
['states', 'qld', 'suburbs', 'emerald']. An empty path (/or"") targets the root resource (rootPathSubNamespace, defaultHome). -
Check the method. If it isn't in the supported set, short-circuit to
405(or404if the URL has no handlers at all). -
Walk the segments, left to right. For each segment the router tries to extend the current namespace by appending the class-name form of the segment. If an action class exists at that extended namespace, the segment is a namespace component and begins a new level; otherwise it is an argument collected against the current level:
segment extends to role statesStatesnamespace qldStates\Qld? (none)argument of the StateslevelsuburbsStates\Suburbsnamespace emeraldStates\Suburbs\Emerald? (none)argument of the States\Suburbslevel -
Match a handler at each level. Candidate class suffixes are tried in priority order —
Action→ Collection (AllAction) → Item (OneAction) — and the first whose signature fits the bound arguments wins. TryingActionfirst is what lets/persons/schemaresolve toPersons\Schema\GetActioneven whenPersons\Schema\GetOneActionalso exists. -
Resolve the arguments — in this order:
- Inherited first (RESTful only). A Collection/Item child starts with the parameters and bound arguments inherited from its matched parent (
qldflows down toStates\Suburbs\GetOneAction). AnActionroute inherits nothing. - Then local segments. The level's own segments fill the remaining parameters, left to right. Each segment is cast to the parameter's declared type; if no declared type accepts it, the candidate becomes a
422candidate. - Then the variadic. Any leftover segments must be absorbed by a trailing variadic parameter (
...$args); if there's no variadic, the candidate doesn't fit.
So
/states/qld/suburbs/emeraldbinds['qld', 'emerald']toStates\Suburbs\GetOneAction(string $state, string $suburb)—qldinherited from the parent,emeraldfrom the local segment. - Inherited first (RESTful only). A Collection/Item child starts with the parameters and bound arguments inherited from its matched parent (
-
Build the result. A matched chain becomes a
200carrying the primary (deepest) handler and its bound arguments. A failure becomes400/404/422; method discovery produces405or an auto-synthesised204forOPTIONS.
Route types and their intentions
| Type | Class suffix | Intent | Inherits parent args? |
|---|---|---|---|
| Collection | GetAllAction |
Operate on a whole collection (list, create-into). | Yes |
| Item | GetOneAction |
Operate on a single addressed member of a collection; consumes the id segment. | Yes |
| Action | GetAction |
A verb / static / vanity route that bypasses RESTful semantics. Binds only its own trailing segments and wins over RESTful handlers at the same namespace. | No |
Collection and Item are the RESTful types: they model resources and chain together (/states/{state}/suburbs/{suburb}), each inheriting its parent's identifying arguments. Action is the escape hatch: a fixed route that behaves "as if the file existed at that path," for cases the RESTful conventions shouldn't own.
Contributing
See CONTRIBUTING.md.