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
// 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
// app/bootstrap.php — inside the boot callback
$app->loadRoutes($root . '/app/routes.php');Methods
<?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
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
// 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'));Parameters
<?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
public function handle(ServerRequest $request): Response
{
$id = $request->attribute('id'); // string|null
$slug = $request->attribute('slug');
return Response::text("{$id} / {$slug}");
}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
$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:
PhpOrbit\Routing\Exception\InvalidRoutePattern:
Route pattern "/orders/{id:[0-9}" compiles to an invalid regex; check its constraints.Grouping
By path prefix
<?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
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');
});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
$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
$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
$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'); // trueGeneration is strict, so a broken link surfaces where it is built rather than as a 404 for whoever clicks it:
<?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:
/docsand/docs/address the same route. - No match gives 404; wrong method gives 405 with an
Allowheader listing what is accepted.
$ curl -i -X POST http://127.0.0.1:8080/articles/1
HTTP/1.1 405 Method Not Allowed
Allow: GET, DELETEDebug-only routes
The closure receives the debug flag, so a route can exist only while debugging without the file reading the environment itself:
<?php
if ($debug) {
$routes->get('/__routes', static fn (): Response => Response::text('...'), 'debug.routes');
}Inspecting the table
$ ./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