Skip to content

Routing

Marko uses PHP attributes to define routes directly on controller methods. No separate route files, no registration boilerplate.

app/blog/Controller/PostController.php
<?php
declare(strict_types=1);
namespace App\Blog\Controller;
use Marko\Routing\Attributes\Get;
use Marko\Routing\Attributes\Post;
use Marko\Routing\Attributes\Delete;
use Marko\Http\ResponseInterface;
use Marko\Http\JsonResponse;
class PostController
{
#[Get('/posts')]
public function index(): ResponseInterface
{
return new JsonResponse(data: ['posts' => []]);
}
#[Get('/posts/{id}')]
public function show(int $id): ResponseInterface
{
return new JsonResponse(data: ['id' => $id]);
}
#[Post('/posts')]
public function store(): ResponseInterface
{
return new JsonResponse(data: ['created' => true], status: 201);
}
#[Delete('/posts/{id}')]
public function destroy(int $id): ResponseInterface
{
return new JsonResponse(status: 204);
}
}
AttributeHTTP Method
#[Get]GET
#[Post]POST
#[Put]PUT
#[Patch]PATCH
#[Delete]DELETE

Parameters in {braces} are automatically injected into the method by name:

#[Get('/users/{userId}/posts/{postId}')]
public function show(int $userId, int $postId): ResponseInterface
{
// $userId and $postId are extracted from the URL
}

Apply middleware to routes using the #[Middleware] attribute:

use Marko\Routing\Attributes\Get;
use Marko\Routing\Attributes\Middleware;
use Marko\Authentication\Middleware\AuthMiddleware;
class AdminController
{
#[Get('/admin/dashboard')]
#[Middleware(AuthMiddleware::class)]
public function dashboard(): ResponseInterface
{
// Only authenticated users reach this
}
}
#[Get('/admin/settings')]
#[Middleware(AuthMiddleware::class)]
#[Middleware(AdminRoleMiddleware::class)]
public function settings(): ResponseInterface
{
// Must be authenticated AND have admin role
}

Marko detects route conflicts at boot time, not at request time. If two controllers register the same path and method, you get a loud error immediately — not a mysterious 404 in production.

Higher-priority modules can override routes from lower-priority modules. If vendor/marko/blog defines GET /posts and your app/blog also defines GET /posts, your version wins.

To remove a vendor route without replacing it, use the #[DisableRoute] attribute. Place it on a method that also has the route attribute you want to disable --- #[DisableRoute] takes no parameters and simply disables the route defined by the preceding routing attribute:

use Marko\Routing\Attributes\Get;
use Marko\Routing\Attributes\DisableRoute;
class BlogRouteOverrides
{
#[Get('/blog/rss')]
#[DisableRoute]
public function disableRss(): void {}
}

Middleware implements MiddlewareInterface:

app/myapp/Middleware/RateLimitMiddleware.php
<?php
declare(strict_types=1);
namespace App\MyApp\Middleware;
use Marko\Http\RequestInterface;
use Marko\Http\ResponseInterface;
use Marko\Routing\Middleware\MiddlewareInterface;
class RateLimitMiddleware implements MiddlewareInterface
{
public function handle(
RequestInterface $request,
callable $next,
): ResponseInterface {
// Before the controller
$this->checkRateLimit($request);
// Call the next middleware or controller
$response = $next($request);
// After the controller
return $response->withHeader('X-RateLimit-Remaining', '99');
}
}