Routing

The router maps HTTP requests to controller actions. It supports route parameters, groups with shared middleware, and RESTful API resource shortcuts — all with a fluent, chainable API.

Registering Routes

Use the router methods that correspond to HTTP verbs. Each method accepts a URL pattern, a controller class name, and the action method to invoke. Every method returns $this, so you can chain calls.

$router->get('/users', UserController::class, 'index');
$router->post('/users', UserController::class, 'store');
$router->put('/users/{id}', UserController::class, 'update');
$router->delete('/users/{id}', UserController::class, 'destroy');
$router->patch('/users/{id}', UserController::class, 'partialUpdate');

The full method signatures are identical across all verbs:

$router->get(string $path, string $controller, string $action, array $middleware = []): self
$router->post(string $path, string $controller, string $action, array $middleware = []): self
$router->put(string $path, string $controller, string $action, array $middleware = []): self
$router->delete(string $path, string $controller, string $action, array $middleware = []): self
$router->patch(string $path, string $controller, string $action, array $middleware = []): self

Tip: Routes are matched in the order they are registered. Place more specific routes before general ones to avoid unexpected matches.

You can chain multiple registrations together:

$router
    ->get('/dashboard', DashboardController::class, 'index')
    ->get('/dashboard/stats', DashboardController::class, 'stats')
    ->post('/dashboard/export', DashboardController::class, 'export');

Route Parameters

Use {name} placeholders in route patterns to capture dynamic segments. Each placeholder matches one or more characters (excluding /) and is passed as a method argument to the controller action.

// Route registration
$router->get('/users/{id}', UserController::class, 'show');

// Controller action receives the parameter as a method argument
class UserController extends ApiController
{
    public function show(string $id): JsonResponse
    {
        // $id = '42' when the request path is /users/42
        $user = $this->userService->getById((int) $id);
        return $this->json($user->toArray());
    }
}

Routes can have multiple parameters:

$router->get('/teams/{teamId}/members/{memberId}', TeamController::class, 'showMember');

// Controller action
public function showMember(string $teamId, string $memberId): JsonResponse
{
    // $teamId = '5', $memberId = '12' for /teams/5/members/12
}

Note: Route parameters are always passed as strings. Cast them to the appropriate type in your controller action (e.g., (int) $id).

Internally, each {name} placeholder is converted to the regex pattern (?P<name>[^/]+). This means a parameter matches any character except a forward slash.

Route Groups

Groups let you apply a shared URL prefix and middleware to a set of routes. This keeps your route definitions clean and avoids repeating common prefixes or middleware arrays.

$router->group(string $prefix, callable $callback, array $middleware = []): self

Basic Grouping

The prefix is prepended to every route registered inside the callback:

$router->group('/api', function (Router $router) {
    $router->get('/users', UserController::class, 'index');    // GET /api/users
    $router->get('/posts', PostController::class, 'index');    // GET /api/posts
});

Groups with Middleware

Pass a middleware array as the third argument. Every route in the group inherits that middleware:

use Melodic\Security\ApiAuthenticationMiddleware;

$router->group('/api', function (Router $router) {
    $router->get('/profile', ProfileController::class, 'show');     // authenticated
    $router->put('/profile', ProfileController::class, 'update');   // authenticated
}, middleware: [ApiAuthenticationMiddleware::class]);

Nested Groups

Groups can be nested. Prefixes and middleware accumulate from the outermost group inward:

$router->group('/api', function (Router $router) {
    $router->group('/v1', function (Router $router) {
        $router->get('/users', UserController::class, 'index');  // GET /api/v1/users
        $router->get('/posts', PostController::class, 'index');  // GET /api/v1/posts
    });

    $router->group('/v2', function (Router $router) {
        $router->get('/users', UserV2Controller::class, 'index'); // GET /api/v2/users
    });
}, middleware: [ApiAuthenticationMiddleware::class]);

In this example, all routes under both /api/v1 and /api/v2 inherit the ApiAuthenticationMiddleware.

Tip: Use an empty prefix ('') if you want to apply middleware to a group of routes without changing their paths.

// Apply optional auth to public pages without changing their paths
$router->group('', function (Router $router) {
    $router->get('/', HomeController::class, 'index');
    $router->get('/about', HomeController::class, 'about');
}, middleware: [OptionalWebAuthMiddleware::class]);

API Resources

The apiResource method registers a complete set of RESTful routes for a resource with a single call:

$router->apiResource(string $path, string $controller, array $middleware = []): self

This registers the following five routes:

HTTP MethodURL PatternController ActionPurpose
GET/pathindexList all resources
GET/path/{id}showShow a single resource
POST/pathstoreCreate a new resource
PUT/path/{id}updateReplace a resource
DELETE/path/{id}destroyDelete a resource

Example

$router->apiResource('/api/users', UserApiController::class);

This is equivalent to writing:

$router->get('/api/users', UserApiController::class, 'index');
$router->get('/api/users/{id}', UserApiController::class, 'show');
$router->post('/api/users', UserApiController::class, 'store');
$router->put('/api/users/{id}', UserApiController::class, 'update');
$router->delete('/api/users/{id}', UserApiController::class, 'destroy');

API Resources Inside Groups

When used inside a group, the group prefix is prepended to the resource path:

$router->group('/api', function (Router $router) {
    $router->apiResource('/users', UserApiController::class);
    $router->apiResource('/posts', PostApiController::class);
}, middleware: [ApiAuthenticationMiddleware::class]);

This registers routes like GET /api/users, POST /api/users, GET /api/posts/{id}, etc. — all protected by ApiAuthenticationMiddleware.

Route Matching

The match method finds the first route that matches a given HTTP method and path:

$router->match(HttpMethod $method, string $path): ?array

It returns an associative array on success, or null if no route matches:

$result = $router->match(HttpMethod::GET, '/users/42');

if ($result !== null) {
    $route  = $result['route'];   // Route object
    $params = $result['params'];  // ['id' => '42']
}

Note: You typically do not call match() directly. The RoutingMiddleware handles this automatically as part of the request pipeline.

Route-Level Middleware

Middleware can be attached at two levels: on individual routes, or on route groups. In both cases, middleware is specified as an array of class names.

Per-Route Middleware

Pass a middleware array as the fourth argument to any route registration method:

use Melodic\Security\ApiAuthenticationMiddleware;
use Melodic\Security\AuthorizationMiddleware;

$router->get('/admin/stats', AdminController::class, 'stats', [
    ApiAuthenticationMiddleware::class,
    AuthorizationMiddleware::class,
]);

Combined Group and Route Middleware

When a route has its own middleware and lives inside a group with middleware, both sets are merged. Group middleware runs first, followed by route-level middleware:

$router->group('/api', function (Router $router) {
    // This route has both group middleware (auth) and route middleware (authz)
    $router->delete('/users/{id}', UserController::class, 'destroy', [
        AuthorizationMiddleware::class,
    ]);
}, middleware: [ApiAuthenticationMiddleware::class]);

// Effective middleware chain: ApiAuthenticationMiddleware → AuthorizationMiddleware

Loading Routes from a File

For larger applications, define your routes in a dedicated file and return a callable. This keeps your entry point clean:

// config/routes.php
<?php

use App\Controllers\HomeController;
use App\Controllers\UserApiController;
use Melodic\Routing\Router;
use Melodic\Security\ApiAuthenticationMiddleware;

return function (Router $router): void {
    $router->get('/', HomeController::class, 'index');

    $router->group('/api', function (Router $router) {
        $router->apiResource('/users', UserApiController::class);
    }, middleware: [ApiAuthenticationMiddleware::class]);
};

Then load it in your application bootstrap:

// public/index.php
$app = new Application(dirname(__DIR__));
$app->loadEnvironmentConfig();
$app->routes(require __DIR__ . '/../config/routes.php');
$app->run();

Since $app->routes() accepts any callable, the require expression returns the closure, which is then invoked with the Router instance.

The RoutingMiddleware

The RoutingMiddleware is the bridge between the middleware pipeline and your controllers. It is automatically added as the final handler in the application pipeline.

Here is what happens on each request:

  1. Match the route — calls $router->match() with the request method and path
  2. Handle no match — if no route matches, returns a 404 JSON response immediately
  3. Store route parameters — each captured parameter is set as a request attribute via $request->withAttribute()
  4. Build route middleware pipeline — if the matched route has middleware, a mini-pipeline is constructed. Each middleware class is resolved from the DI container.
  5. Resolve the controller — the controller class is resolved from the DI container (with auto-wired dependencies)
  6. Set the request$controller->setRequest($request) is called so the controller has access to the current request
  7. Resolve action arguments — route parameters are matched to action arguments by name. Any parameter typed as a Model subclass is hydrated from the request body via Model::fromArray() and validated. If validation fails, a 400 JSON response is returned immediately.
  8. Call the action — the controller action method is invoked with the resolved arguments
  9. Return the response — the Response object from the controller action is sent back through the pipeline
// Simplified flow inside RoutingMiddleware::process()
$result = $this->router->match($request->method(), $request->path());

if ($result === null) {
    return new JsonResponse(['error' => 'Not Found'], 404);
}

$route  = $result['route'];
$params = $result['params'];

// Store params as request attributes
foreach ($params as $name => $value) {
    $request = $request->withAttribute($name, $value);
}

// Resolve controller from DI, set request, resolve arguments, call action
$controller = $this->container->get($route->controller);
$controller->setRequest($request);

// Route params matched by name; Model-typed params hydrated from request body
$args = $this->resolveActionArguments($request);

return $controller->{$route->action}(...$args);

Automatic model binding. Action parameters typed as a Melodic\Data\Model subclass are automatically hydrated from the request body and validated. See the Validation docs for details.

The HttpMethod Enum

Melodic uses a backed PHP enum for HTTP methods. The router and request both use this enum rather than raw strings:

namespace Melodic\Http;

enum HttpMethod: string
{
    case GET     = 'GET';
    case POST    = 'POST';
    case PUT     = 'PUT';
    case DELETE  = 'DELETE';
    case PATCH   = 'PATCH';
    case OPTIONS = 'OPTIONS';
}

The enum includes a parse method for converting a string to the enum value:

$method = HttpMethod::parse('POST');   // HttpMethod::POST
$method = HttpMethod::parse('get');    // HttpMethod::GET (case-insensitive)
$method = HttpMethod::parse('BOGUS');  // throws ValueError

Note: The Request class calls HttpMethod::parse() internally when it is constructed, so by the time your controller receives the request, $this->request->method() always returns a valid HttpMethod enum case.

Complete Example

Here is a full route configuration file that demonstrates groups, nesting, API resources, individual routes, and middleware:

<?php
// config/routes.php

use App\Controllers\HomeController;
use App\Controllers\DocsController;
use App\Controllers\UserApiController;
use App\Controllers\AdminController;
use App\Middleware\RateLimitMiddleware;
use Melodic\Routing\Router;
use Melodic\Security\ApiAuthenticationMiddleware;
use Melodic\Security\AuthorizationMiddleware;
use Melodic\Security\OptionalWebAuthMiddleware;

return function (Router $router): void {
    // Public pages with optional auth
    $router->group('', function (Router $router) {
        $router->get('/', HomeController::class, 'index');
        $router->get('/about', HomeController::class, 'about');
    }, middleware: [OptionalWebAuthMiddleware::class]);

    // Documentation
    $router->get('/docs', DocsController::class, 'index');
    $router->get('/docs/{page}', DocsController::class, 'show');

    // API v1 — all routes require authentication
    $router->group('/api/v1', function (Router $router) {
        // RESTful resources
        $router->apiResource('/users', UserApiController::class);

        // Custom route with extra middleware
        $router->delete('/users/{id}', AdminController::class, 'removeUser', [
            AuthorizationMiddleware::class,
        ]);
    }, middleware: [ApiAuthenticationMiddleware::class]);
};