Routing

Declaring routes, capturing parameters, grouping, naming, and generating URLs.

Routes live in app/routes.php, which returns a closure. The file is loaded during the boot phase, so routes still land before the table is compiled — living in their own file changes where they are written, not when they take effect.

PHP
<?php
// app/routes.php
use App\Controllers\HomeController;
use PhpOrbit\Routing\RouteCollection;

return static function (RouteCollection $routes, bool $debug): void {
    $routes->get('/', HomeController::class, 'home');
};
PHP
<?php
// app/bootstrap.php — inside the boot callback
$app->loadRoutes($root . '/app/routes.php');

Methods

PHP
<?php
$routes->get('/articles', IndexController::class, 'articles.index');
$routes->post('/articles', StoreController::class, 'articles.store');
$routes->put('/articles/{id}', ReplaceController::class, 'articles.replace');
$routes->patch('/articles/{id}', UpdateController::class, 'articles.update');
$routes->delete('/articles/{id}', DeleteController::class, 'articles.delete');

HEAD is served automatically by the matching GET route; the kernel strips the body and leaves the headers identical. For anything else, use add():

PHP
<?php
use PhpOrbit\Http\Method;

$routes->add(Method::Post, '/webhooks/stripe', StripeWebhook::class, 'webhooks.stripe', csrfExempt: true);

Handlers

A route points at either a controller class or a closure.

PHP
<?php
// A class implementing Handler. Constructor dependencies are autowired.
$routes->get('/articles', IndexController::class, 'articles.index');

// A closure, for things too small to deserve a file.
$routes->get('/ping', static fn (): Response => Response::json(['pong' => true]));

// A closure receives the request and the request scope.
$routes->get('/whoami', static fn (ServerRequest $request, RequestScope $scope): Response =>
    Response::text($scope->get(Session::class)->get('name') ?? 'guest'));

More on controllers →

Parameters

PHP
<?php
$routes->get('/users/{id}', ShowUser::class, 'users.show');
$routes->get('/users/{id}/posts/{slug}', ShowPost::class, 'posts.show');

Captured values arrive as request attributes:

PHP
<?php
public function handle(ServerRequest $request): Response
{
    $id = $request->attribute('id');        // string|null
    $slug = $request->attribute('slug');

    return Response::text("{$id} / {$slug}");
}
A parameter never spans path segments

An unconstrained {name} matches [^/]+. /files/{name} will not match /files/a/b. Placeholders that quietly swallow slashes are a common source of routes matching far more than their author intended.

Constraints

PHP
<?php
$routes->get('/orders/{id:\d+}', ShowOrder::class, 'orders.show');
$routes->get('/posts/{slug:[a-z0-9-]+}', ShowPost::class, 'posts.show');
$routes->get('/reports/{year:\d{4}}/{month:\d{2}}', Report::class, 'reports.show');

The constraint is a regular expression spliced into the compiled pattern. A broken one fails at boot, not on the first request that happens to reach the route:

Output
PhpOrbit\Routing\Exception\InvalidRoutePattern:
  Route pattern "/orders/{id:[0-9}" compiles to an invalid regex; check its constraints.

Grouping

By path prefix

PHP
<?php
$routes->group('/api/v1', static function (RouteCollection $routes): void {
    $routes->get('/users', ApiUsers::class, 'api.users');       // /api/v1/users
    $routes->get('/orders', ApiOrders::class, 'api.orders');    // /api/v1/orders
});

Groups nest, and the prefix is restored in a finally — a throwing callback cannot leak its prefix onto routes declared afterwards.

By middleware

PHP
<?php
use PhpOrbit\Auth\RequireAuthentication;

$routes->withMiddleware([new RequireAuthentication()], static function (RouteCollection $routes): void {
    $routes->post('/articles', StoreController::class, 'articles.store');
    $routes->post('/articles/{id:\d+}/delete', DeleteController::class, 'articles.delete');
});
State a guard once

withMiddleware() is a prefix-less group() with its own name, because “these require a signed-in user” and “these live under /admin” are different statements. Prefer it to repeating the guard per line — a guard repeated on every line is one that eventually gets left off one of them.

Both can be combined:

PHP
<?php
$routes->group('/admin', static function (RouteCollection $routes): void {
    $routes->get('/', Dashboard::class, 'admin.home');
    $routes->get('/users', AdminUsers::class, 'admin.users');
}, [new RequireAuthentication('/login')]);

Per-route middleware

PHP
<?php
$routes->get('/reports', ReportController::class, 'reports', middleware: [
    new RequireAuthentication(),
    new RateLimit(perMinute: 10),
]);

Route middleware runs inside the global stack, after everything registered with $app->middleware(...).

Names and URL generation

Naming a route means a path can change in one place.

PHP
<?php
$router = $app->router();

$router->urlFor('home');                              // "/"
$router->urlFor('users.show', ['id' => 42]);          // "/users/42"
$router->urlFor('posts.show', ['slug' => 'a b/c']);   // "/posts/a%20b%2Fc"
$router->hasName('users.show');                       // true

Generation is strict, so a broken link surfaces where it is built rather than as a 404 for whoever clicks it:

PHP
<?php
$router->urlFor('users.show');
// UnknownRoute: Route "users.show" needs a value for {id}.

$router->urlFor('orders.show', ['id' => 'abc']);   // route is {id:\d+}
// UnknownRoute: The values given for route "orders.show" do not satisfy its pattern "/orders/{id:\d+}".

$router->urlFor('typo');
// UnknownRoute: No route is named "typo". Known names: home, users.show, posts.show.

Duplicate names are rejected at boot too.

How matching works

  • Static routes go into a hash keyed by method and path, so the common case costs one array lookup no matter how many routes exist.
  • Parameterised routes are scanned in registration order.
  • Trailing slashes are collapsed: /docs and /docs/ address the same route.
  • No match gives 404; wrong method gives 405 with an Allow header listing what is accepted.
Shell
$ curl -i -X POST http://127.0.0.1:8080/articles/1
HTTP/1.1 405 Method Not Allowed
Allow: GET, DELETE

Debug-only routes

The closure receives the debug flag, so a route can exist only while debugging without the file reading the environment itself:

PHP
<?php
if ($debug) {
    $routes->get('/__routes', static fn (): Response => Response::text('...'), 'debug.routes');
}

Inspecting the table

Shell
$ ./orbit routes
GET     /                                        self-check
GET     /avatar                                  avatar
POST    /avatar                                  avatar.store
GET     /health                                  health
GET     /hello/{name}                            hello
GET     /login                                   login
POST    /login                                   login.attempt
POST    /logout                                  logout
GET     /notes                                   notes.index
POST    /notes                                   notes.create
POST    /notes/{id:\d+}/delete                   notes.delete