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 Method | URL Pattern | Controller Action | Purpose |
|---|---|---|---|
| GET | /path | index | List all resources |
| GET | /path/{id} | show | Show a single resource |
| POST | /path | store | Create a new resource |
| PUT | /path/{id} | update | Replace a resource |
| DELETE | /path/{id} | destroy | Delete 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:
- Match the route — calls
$router->match()with the request method and path - Handle no match — if no route matches, returns a
404 JSON responseimmediately - Store route parameters — each captured parameter is set as a request attribute via
$request->withAttribute() - Build route middleware pipeline — if the matched route has middleware, a mini-pipeline is constructed. Each middleware class is resolved from the DI container.
- Resolve the controller — the controller class is resolved from the DI container (with auto-wired dependencies)
- Set the request —
$controller->setRequest($request)is called so the controller has access to the current request - Resolve action arguments — route parameters are matched to action arguments by name. Any parameter typed as a
Modelsubclass is hydrated from the request body viaModel::fromArray()and validated. If validation fails, a400JSON response is returned immediately. - Call the action — the controller action method is invoked with the resolved arguments
- 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]);
};