Skip to content

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].

Terminal window
composer require marko/routing

Add route attributes to controller methods:

ProductController.php
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.

#[Get('/path')]
#[Post('/path')]
#[Put('/path')]
#[Patch('/path')]
#[Delete('/path')]
AdminController.php
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:

AuthMiddleware.php
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);
}
}

Use Preferences to replace a vendor’s controller:

MyPostController.php
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');
}
}

Explicitly remove an inherited route:

MyPostController.php
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
}
}

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.

See all registered routes:

Terminal window
marko route:list
METHOD PATH ACTION MIDDLEWARE
GET / HelloController::index
GET /blog PostController::index
GET /blog/{id} PostController::show
GET /products ProductController::index
GET /products/{id} ProductController::show

Filter by HTTP method or path:

Terminal window
marko route:list --method=POST
marko route:list --path=products
marko route:list --method=GET --path=blog
#[Get(path: '/path', middleware: [])]
#[Post(path: '/path')]
#[Put(path: '/path')]
#[Patch(path: '/path')]
#[Delete(path: '/path')]
#[DisableRoute]
#[Middleware(MiddlewareClass::class)]
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.

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;
}
interface MiddlewareInterface
{
public function handle(Request $request, callable $next): Response;
}

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.