Docs LATEST

Routing

Route definitions, parameters, named routes, and middleware.

HTTP & Routing

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.