waffle-commons / routing
Routing component for Waffle framework.
Requires
- php: ^8.5
- psr/http-message: ^2.0
- waffle-commons/contracts: 0.1.0-beta2.1
- waffle-commons/utils: 0.1.0-beta2.1
Requires (Dev)
- carthage-software/mago: ^1.29
- cyclonedx/cyclonedx-php-composer: ^6.2
- php-mock/php-mock-phpunit: ^2.15
- phpunit/phpunit: ^12.5
- vimeo/psalm: ^6.16
This package is auto-updated.
Last update: 2026-05-30 19:46:56 UTC
README
Waffle Routing Component
Release:
v0.1.0-beta2Β |ΒCHANGELOG.md
Attribute-driven router. No YAML, no XML β routes live next to the controller code via the #[Route] attribute and are discovered by scanning the configured controller directory at boot time. The compiled route table is then cached.
π Beta-2 highlights β HTTP method correctness
- HTTP method filtering & route overloading.
#[Route(methods: ['GET', 'POST'])]constrains a route to specific verbs. Multiple controller actions may share one path provided their methods arrays don't intersect β e.g. aGETandPOSThandler for/articles. Method names are canonicalised (upper-case) and de-duplicated at discovery, so typos likemethods: ['get']are caught at boot, not at runtime. HEAD β GETfallback (RFC 7231 Β§4.3.2). A request with methodHEADmatches aGETroute automatically.OPTIONSauto-answer. WhenWaffle\Commons\Pipeline\CoreRoutingMiddlewareis wired with a PSR-17ResponseFactoryInterface, anOPTIONSrequest to a known path is answered with204 No Content+Allowheader β no controller dispatch required.- Deterministic
Allowheader. When raisingMethodNotAllowedException(HTTP405), the router merges declared methods, auto-augments withHEAD(ifGETis allowed) andOPTIONS, deduplicates, and alphabetically sorts the resulting list β e.g.Allow: GET, HEAD, OPTIONS, POST. The error renderer (waffle-commons/error-handler) copies this verbatim. #[Route]attribute relocation. The canonical attribute now lives atWaffle\Commons\Contracts\Routing\Attribute\Route(in thecontractspackage). The oldWaffle\Commons\Routing\Attribute\Routehas been removed βuse Waffle\Commons\Contracts\Routing\Attribute\Route;everywhere.- Worker-safe PCRE cache. PCRE patterns compiled from route templates are memoised in resident-worker memory and survive across requests for the worker's lifetime.
Beta-1 inheritance
- Priority routing & catch-all.
#[Route]takes anint $priority = 0. At boot the router sorts the compiled table by descending priority, so high-priority specific routes are tried before low-priority ones. Negative priorities (e.g.-1000) flag catch-all routes like/{path:.*}that should only match once every specific route has failed β the foundation for Strangler-Fig / API-gateway proxying. MatchedRouteDTO.matchRequest()returns a typedWaffle\Commons\Contracts\Routing\MatchedRoute(ornull) instead of a loose array.- Reflection cleanup. Discovery reads
#[Route]via nativeReflectionClass/ReflectionMethod::getAttributes()β the oldReflectionTraitwas removed in Beta-1.
π¦ Installation
composer require waffle-commons/routing
π§± Surface
| Class | Role |
|---|---|
Waffle\Commons\Routing\Router |
RouterInterface implementation. Boots from a ContainerInterface, sorts the table by descending priority, and returns a ?MatchedRoute from matchRequest(). |
Waffle\Commons\Routing\RouteDiscoverer |
Walks the controller directory and builds a list<MatchedRoute> from each controller's #[Route] attributes. |
Waffle\Commons\Routing\ControllerFinder |
Filesystem traversal of *.php controller files. |
Waffle\Commons\Routing\RouteParser |
Builds a MatchedRoute from a Route attribute + native reflection metadata (ReflectionClass/ReflectionMethod). A method-level priority overrides the class-level default. |
Waffle\Commons\Contracts\Routing\Attribute\Route |
The #[Route(path, methods, name, arguments, priority)] attribute (defined in contracts). |
Waffle\Commons\Routing\Attribute\Argument |
The #[Argument(classType, paramName, required)] attribute for routes that auto-resolve container services. |
Waffle\Commons\Routing\Trait\RequestTrait |
Helpers for extracting routing data from a PSR-7 request (_controller, _route_params). |
π #[Route] attribute β exact signature
From Waffle\Commons\Contracts\Routing\Attribute\Route (the attribute lives in contracts, so every component reads the same definition):
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)] final readonly class Route { /** * @param array<string> $methods Allowed HTTP methods. Default ['GET']; empty [] accepts any. * @param array<Argument>|null $arguments * @param int $priority Higher matches first. Use negative * values (e.g. -1000) for catch-all routes. */ public function __construct( public string $path, public array $methods = ['GET'], public ?string $name = null, public ?array $arguments = null, public int $priority = 0, ) {} }
Argument (from src/Attribute/Argument.php):
#[Attribute] final class Argument { public function __construct( public string $classType, public string $paramName, public bool $required = true, ) {} }
π Declaring routes on a controller
use Waffle\Commons\Contracts\Routing\Attribute\Route; use Waffle\Commons\Routing\Attribute\Argument; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; final class UserController { #[Route(path: '/users/{id}', name: 'users.show')] public function show(ServerRequestInterface $request): ResponseInterface { // The {id} placeholder is exposed via $request->getAttribute('_route_params')['id'] } #[Route( path: '/users', name: 'users.create', arguments: [ new Argument(classType: UserRepository::class, paramName: 'repo'), ], )] public function create(ServerRequestInterface $request, UserRepository $repo): ResponseInterface { // $repo is auto-injected via the container. } }
π Matching a request
use Waffle\Commons\Routing\Router; use Waffle\Commons\Container\Container; $router = new Router(/* β¦discoverer, cacheβ¦ */); $router->boot($container); $match = $router->matchRequest($psr7Request); /* @var \Waffle\Commons\Contracts\Routing\MatchedRoute|null $match * * MatchedRoute exposes: className, method, arguments, path, name, params, priority. * Use $match->withParams([...]) to attach the captured path parameters immutably. */
null means no route matched β the kernel converts that into RouteNotFoundException (which the error handler renders as RFC 7807 404). Routes are evaluated in descending priority order, so a catch-all (priority: -1000) is only reached after every specific route has failed.
π HTTP method filtering
use Waffle\Commons\Contracts\Routing\Attribute\Route; final class ArticleController { #[Route(path: '/articles', methods: ['GET'], name: 'articles.list')] public function list(): ResponseInterface { /* β¦ */ } #[Route(path: '/articles', methods: ['POST'], name: 'articles.create')] public function create(): ResponseInterface { /* β¦ */ } }
- Matching is case-insensitive; methods are upper-cased + de-duplicated at discovery.
- A
GETroute also answersHEAD(RFC 7231 Β§4.3.2). OPTIONSto a known path is auto-answered204+AllowwhenCoreRoutingMiddlewareis wired with a PSR-17 response factory.- A method mismatch throws
MethodNotAllowedException(405); itsAllowheader is merged across candidates, augmented (HEADifGET, alwaysOPTIONS), de-duplicated, and sorted β e.g.Allow: GET, HEAD, OPTIONS, POST.
π PHP 8.5 features used
#[Route],#[Argument]β PHP 8 attribute syntax with PHP 8.5 typed properties.finalclasses throughout.- Strongly-typed
MatchedRouteDTO return fromRouterInterface::matchRequest()(no loose arrays). - Native reflection (
ReflectionClass/ReflectionMethod::getAttributes()) to read#[Route]β the oldReflectionTraitwas removed in Beta-1.
π§ Architectural boundary (mago guard)
An active dependency perimeter is enforced on every CI run by vendor/bin/mago guard (bundled into composer mago; zero baselines). The rules live in mago.toml under [guard.perimeter] β a forbidden use statement fails the build, not a reviewer.
Production code under Waffle\Commons\Routing may depend only on:
Waffle\Commons\Routing\**β itselfWaffle\Commons\Contracts\**β the shared contracts package (where#[Route],MatchedRoute, andMethodNotAllowedExceptionnow live)Waffle\Commons\Utils\**β theClassParsercontroller-discovery helperPsr\**β PSR interfaces (PSR-7)@global+Psl\**β PHP core and the PHP Standard Library
Test code under WaffleTests\Commons\Routing is unrestricted (@all). Structural rules are guarded too: interfaces must be named *Interface, Exception\** classes must end in *Exception, and any Enum\** namespace may hold only enum declarations.
Contract-first, component-agnostic by construction: components compose through waffle-commons/contracts (plus the explicitly-permitted utils), never ad-hoc through one another.
π§ͺ Testing
docker exec -w /waffle-commons/routing waffle-dev composer tests
π License
MIT β see LICENSE.md.