Routing
Route definitions, parameters, named routes, and middleware.
Routes map URLs to handlers. VelvetCMS provides a fluent router with support for parameters, middleware, and named routes.
Basic Routes #
$router->get('/about', [AboutController::class, 'show']);
$router->post('/contact', [ContactController::class, 'submit']);
$router->put('/posts/{id}', [PostController::class, 'update']);
$router->delete('/posts/{id}', [PostController::class, 'destroy']);
$router->patch('/posts/{id}', [PostController::class, 'patch']);
Closure Handlers #
For simple routes, use closures instead of controllers:
$router->get('/health', function (Request $request) {
return Response::json(['status' => 'ok']);
});
Any Method #
Match all HTTP methods:
$router->any('/webhook', [WebhookController::class, 'handle']);
Route Parameters #
Required Parameters #
Capture URL segments with {name}:
$router->get('/posts/{slug}', function (Request $request, string $slug) {
return "Post: {$slug}";
});
$router->get('/users/{id}/posts/{postId}', function (Request $request, string $id, string $postId) {
// Multiple parameters
});
Parameters are passed to your handler in order after the Request.
Optional Parameters #
Add ? to make a parameter optional:
$router->get('/archive/{year?}', function (Request $request, ?string $year = null) {
$year = $year ?? date('Y');
return "Archive for {$year}";
});
Wildcard Parameters #
Capture everything including slashes with *:
$router->get('/docs/{path*}', function (Request $request, string $path) {
// $path could be "getting-started/installation"
return $this->docs->render($path);
});
Wildcards are greedy-they capture the entire remaining path.
Named Routes #
Give routes names to generate URLs programmatically:
$router->get('/posts/{slug}', [PostController::class, 'show'], 'posts.show');
$router->get('/users/{id}', [UserController::class, 'profile'], 'users.profile');
Generate URLs from names:
$url = $router->url('posts.show', ['slug' => 'hello-world']);
// "/posts/hello-world"
$url = $router->url('users.profile', ['id' => 123]);
// "/users/123"
Middleware #
Per-Route Middleware #
Attach middleware to specific routes:
$router->get('/dashboard', [DashboardController::class, 'index'])
->middleware('auth');
$router->post('/admin/settings', [SettingsController::class, 'update'])
->middleware(['auth', 'admin']);
Middleware Aliases #
Register aliases in config/http.php under middleware.aliases:
'middleware' => [
'aliases' => [
'auth' => AuthMiddleware::class,
'admin' => AdminMiddleware::class,
'throttle' => ThrottleRequests::class,
],
],
Then use aliases in routes:
$router->get('/api/posts', [ApiController::class, 'posts'])
->middleware('throttle');
Global Middleware #
Middleware that runs on every request, configured in config/http.php under middleware.global:
'middleware' => [
'global' => [
'errors',
'session',
'throttle',
],
],
Controller Handlers #
Controllers receive the Request and route parameters:
class PostController
{
public function show(Request $request, string $slug): Response
{
$post = $this->posts->findBySlug($slug);
if (!$post) {
return Response::notFound();
}
return Response::html($this->view->render('post', ['post' => $post]));
}
}
Controllers are resolved through the container, so dependencies are injected automatically:
class PostController
{
public function __construct(
private readonly PageService $posts,
private readonly ViewEngine $view
) {}
}
Method Spoofing #
HTML forms only support GET and POST. For PUT, PATCH, and DELETE, add a _method field:
<form method="POST" action="/posts/123">
<input type="hidden" name="_method" value="DELETE">
<button type="submit">Delete</button>
</form>
The router detects _method on POST requests and routes accordingly.
Return Values #
Handlers can return:
- Response object - used as-is
- String - wrapped in HTML response
- Array - JSON encoded
// All valid:
return Response::html('<h1>Hello</h1>');
return '<h1>Hello</h1>';
return ['status' => 'ok', 'data' => $items];
Route Groups #
Group related routes to share a prefix, middleware, or both:
$router->group(['prefix' => '/api', 'middleware' => 'throttle'], function (Router $r) {
$r->get('/posts', [PostController::class, 'index']);
$r->get('/posts/{id}', [PostController::class, 'show']);
});
// Matches /api/posts and /api/posts/{id}, all throttled
Nested Groups #
Groups can be nested. Prefixes stack and middleware merges:
$router->group(['prefix' => '/api', 'middleware' => 'throttle'], function (Router $r) {
$r->group(['prefix' => '/v1', 'middleware' => 'auth'], function (Router $r) {
$r->get('/users', [UserController::class, 'index']);
// Path: /api/v1/users — middleware: throttle + auth
});
});
Group Isolation #
Groups don't leak. Routes defined outside a group are unaffected:
$router->group(['prefix' => '/admin', 'middleware' => 'auth'], function (Router $r) {
$r->get('/dashboard', [AdminController::class, 'index']);
});
$router->get('/public', fn () => 'no auth here');
// /public has no prefix or middleware from the group
Route Middleware on Grouped Routes #
Per-route middleware stacks on top of group middleware:
$router->group(['middleware' => 'auth'], function (Router $r) {
$r->get('/settings', [SettingsController::class, 'index'])
->middleware('verified');
// Middleware order: auth → verified
});
Route Caching #
For production, cache compiled routes:
./velvet route:cache
Clear with:
./velvet route:clear
Cached routes load faster but won't reflect changes until you clear and rebuild.
Listing Routes #
See all registered routes:
./velvet route:list
HEAD Requests #
HEAD requests automatically fall back to matching GET routes. The response body is stripped, keeping only headers.