marko/routing
Routes live on the methods they handle. Conflicts are caught at boot time with clear error messages. Override vendor routes cleanly via Preferences, or disable them explicitly with #[DisableRoute].
Installation
Section titled “Installation”composer require marko/routingDefining Routes
Section titled “Defining Routes”Add route attributes to controller methods:
use Marko\Routing\Attributes\Get;use Marko\Routing\Attributes\Post;use Marko\Routing\Http\Response;
class ProductController{ #[Get('/products')] public function index(): Response { return new Response('Product list'); }
#[Get('/products/{id}')] public function show( int $id, ): Response { return new Response("Product $id"); }
#[Post('/products')] public function store(): Response { return new Response('Created', 201); }}Route parameters, POST body values, and query string values are automatically resolved and passed to method arguments. Typed scalar parameters (int, float, bool, string) are cast to the declared type. If a required typed scalar parameter cannot be found in the route, POST body, or query string, the router returns a 400 response (via InvalidRouteParameterException) instead of throwing a TypeError.
Available Methods
Section titled “Available Methods”#[Get('/path')]#[Post('/path')]#[Put('/path')]#[Patch('/path')]#[Delete('/path')]Adding Middleware
Section titled “Adding Middleware”use Marko\Routing\Attributes\Middleware;
class AdminController{ #[Get('/admin/dashboard')] #[Middleware(AuthMiddleware::class)] public function dashboard(): Response { return new Response('Admin dashboard'); }}Middleware classes implement MiddlewareInterface:
use Marko\Routing\Http\Request;use Marko\Routing\Http\Response;use Marko\Routing\Middleware\MiddlewareInterface;
class AuthMiddleware implements MiddlewareInterface{ public function handle( Request $request, callable $next, ): Response { if (!$this->isAuthenticated($request)) { return new Response('Unauthorized', 401); }
return $next($request); }}Overriding Vendor Routes
Section titled “Overriding Vendor Routes”Use Preferences to replace a vendor’s controller:
use Marko\Core\Attributes\Preference;use Marko\Routing\Attributes\Get;use Vendor\Blog\PostController;
#[Preference(replaces: PostController::class)]class MyPostController extends PostController{ #[Get('/blog')] // Your route takes over public function index(): Response { return new Response('My custom blog'); }}Disabling Routes
Section titled “Disabling Routes”Explicitly remove an inherited route:
use Marko\Routing\Attributes\DisableRoute;
#[Preference(replaces: PostController::class)]class MyPostController extends PostController{ #[DisableRoute] // Removes /blog/{slug} route public function show( string $slug, ): Response { // Method still exists but has no route }}Route Conflicts
Section titled “Route Conflicts”If two modules define the same route, Marko throws RouteConflictException at boot:
Route conflict detected for GET /products
Defined in: - Vendor\Catalog\ProductController::index() - App\Store\ProductController::list()
Resolution: Use #[Preference] to extend one controller,or use #[DisableRoute] to remove one route.Requires marko/cli for the marko binary.
Listing Routes
Section titled “Listing Routes”See all registered routes:
marko route:listMETHOD PATH ACTION MIDDLEWAREGET / HelloController::indexGET /blog PostController::indexGET /blog/{id} PostController::showGET /products ProductController::indexGET /products/{id} ProductController::showFilter by HTTP method or path:
marko route:list --method=POSTmarko route:list --path=productsmarko route:list --method=GET --path=blogAPI Reference
Section titled “API Reference”Route Attributes
Section titled “Route Attributes”#[Get(path: '/path', middleware: [])]#[Post(path: '/path')]#[Put(path: '/path')]#[Patch(path: '/path')]#[Delete(path: '/path')]#[DisableRoute]#[Middleware(MiddlewareClass::class)]Request
Section titled “Request”class Request{ public function method(): string; public function path(): string; public function query(?string $key = null, mixed $default = null): mixed; public function post(?string $key = null, mixed $default = null): mixed; public function body(): string; public function header(string $name, ?string $default = null): ?string; public function headers(): array; public function server(string $key): ?string; public function ip(): ?string; public function withRoute(string $controller, string $action): self; public function controller(): ?string; public function action(): ?string; public static function fromGlobals(): self;}ip() returns REMOTE_ADDR from the server bag (equivalent to server('REMOTE_ADDR')). withRoute() returns a new immutable Request with the matched controller class and action method attached; controller() and action() retrieve them. The router attaches route context before invoking middleware, which allows middleware (such as AdminAuthMiddleware) to inspect which controller method is handling the request.
Response
Section titled “Response”class Response{ public function __construct( string $body = '', int $statusCode = 200, array $headers = [], );
public function body(): string; public function statusCode(): int; public function headers(): array; public function send(): void; public static function json(mixed $data, int $statusCode = 200): self; public static function html(string $html, int $statusCode = 200): self; public static function redirect(string $url, int $statusCode = 302): self;}MiddlewareInterface
Section titled “MiddlewareInterface”interface MiddlewareInterface{ public function handle(Request $request, callable $next): Response;}Parameter Resolution
Section titled “Parameter Resolution”The router resolves controller method parameters in priority order: route path params → POST body → query string → default value. Typed scalars (int, float, bool, string) are automatically cast. A required typed scalar with no matching source throws InvalidRouteParameterException, which the router catches and converts to a 400 response. Route path literals containing dots or other regex metacharacters are matched literally (via preg_quote). URL-encoded path segments are decoded once before matching.