Skip to main content

Defining routes

Registering a route​

Every method has a shortcut on the router. Each returns the new Route, so you can name and constrain it in the same statement.

use Dirthara\Routing\Router;
use Dirthara\Routing\HttpMethod;

$router = new Router();

$router->get('/users', ListUsers::class);
$router->head('/users', CountUsers::class);
$router->post('/users', CreateUser::class);
$router->put('/users/{id}', ReplaceUser::class);
$router->patch('/users/{id}', UpdateUser::class);
$router->delete('/users/{id}', DeleteUser::class);
$router->options('/users', DescribeUsers::class);

$router->add(HttpMethod::Get, '/about', ShowAbout::class);

A handler can be any value. The router stores it and returns it from Route::handler() and RouteMatch::handler(), and never calls it.

Paths and parameters​

A path starts with /. A part of the path in braces, such as {id}, is a parameter: it matches a piece of the request path and is returned by name when the route matches.

$router->get('/users/{user}/posts/{post}', ShowPost::class);
$router->get('/files/{name}.{format}', DownloadFile::class);

A parameter name starts with a letter or an underscore, contains only letters, digits, and underscores, is at most 32 characters long, and appears once per path. A path that breaks these rules, has a { or } outside a parameter, or does not start with / throws an InvalidRouteException when it is registered.

note

Without a constraint, a parameter matches one path segment: one or more characters other than /, the pattern of RoutePattern::Segment. In /files/{name}.{format}, that means {name} can also swallow dots; constrain it if the split matters.

Optional parameters​

A parameter with a ? after its name, such as {page?}, is optional: the route matches with or without it.

$router->get('/posts/{page?}', ListPosts::class)->where('page', RoutePattern::Integer);

That route matches /posts and /posts/2. When the segment is missing, the parameter is left out of RouteMatch::$parameters rather than set to a default, so check for it:

$page = (int) ($match->parameters['page'] ?? 1);

An optional parameter has to be the whole last segment of the path, which makes it the only optional parameter the path can have. /{lang?}/about, /posts/{page?}.json, /posts/page{page?}, and /posts/{page?}/ throw an InvalidRouteException, because with the segment missing it would be ambiguous what the rest of the path should look like. Register a second route for those cases instead.

/{page?} is allowed and matches / as well as /2. A constraint on an optional parameter applies when the segment is there; /posts/last does not match the route above.

Constraining parameters​

where() restricts what a parameter matches with a regular expression. A route whose parameter does not match is skipped, and the router tries the next route.

use Dirthara\Routing\RoutePattern;

$router->get('/users/{id}', ShowUser::class)->where('id', RoutePattern::Integer);
$router->get('/users/{name}', ShowUserByName::class);

$router->get('/reports/{year}/{format}', ShowReport::class)->whereMany([
'year' => '\d{4}',
'format' => 'csv|json',
]);

With those routes, /users/42 matches ShowUser and /users/jane falls through to ShowUserByName.

The pattern always has to match the whole parameter, so write it without ^, $, or delimiters; csv|json matches csv or json and nothing longer. Setting a constraint again replaces the earlier one, so ->where('id', RoutePattern::Segment) puts a parameter back to the default. constraints() returns the constraints set so far, keyed by parameter name, including one set back to the default.

RoutePattern casePatternMatches
Segment[^/]+One or more characters other than /: a whole path segment. This is the default.
Integer\d+One or more digits, such as 42. No sign, but leading zeros and 0 are accepted.
PositiveInteger[1-9]\d*A whole number from 1 up without leading zeros, such as 42. Use it for IDs and page numbers, so /users/7 and /users/007 are not two URLs for the same page.
Alpha[A-Za-z]+ASCII letters, such as FAQ.
Alphanumeric[A-Za-z0-9]+ASCII letters and digits, such as abc123.
Hex[0-9a-fA-F]+Hexadecimal digits in either case, such as a hash or a commit SHA.
Uuid[0-9a-fA-F]{8}-…-[0-9a-fA-F]{12}A UUID in its 8-4-4-4-12 form, in either case.
Ulid[0-7][0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{25}A ULID: 26 Crockford base32 characters in either case, starting with 0 to 7.
Slug[a-z0-9]+(?:-[a-z0-9]+)*Lowercase words joined by single dashes, such as hello-world.
Date\d{4}-(?:0[1-9]|1[0-2])-(?:…)A date in YYYY-MM-DD form with a month from 01 to 12 and a day from 01 to 31, such as 2026-09-24.
Year\d{4}Four digits, such as 2026.
Locale[a-z]{2}(?:-[A-Z]{2})?A language, optionally with a region, such as en or en-US.
caution

Date checks the form and the ranges, not the calendar: 2026-02-31 matches. Parse the value in the handler and treat a date that does not exist as not found. Locale covers two-letter languages and regions only, so a tag such as zh-Hant-TW or es-419 does not match; write your own pattern if you need those.

The letter and digit classes are ASCII only. Parameters are matched in their encoded form, so a letter such as ö arrives as %C3%B6 and never matches Alpha.

A constraint for a parameter the path does not have, or a pattern that is not a valid regular expression, throws an InvalidRouteException. Patterns are delimited with ~ internally, so a pattern that contains ~ is rejected as invalid; write \~ instead.

caution

Parameters are matched against the path as it arrives, still percent-encoded, and decoded afterwards. A constraint sees j%C3%B6rg, not jörg, so write constraints for the encoded form.

Naming a route​

name() gives a route a name, as a string or as an enum case, so you can generate its URL later.

enum Page
{
case Home;
case Contact;
}

$router->get('/', ShowHome::class)->name(Page::Home);
$router->get('/users/{id}', ShowUser::class)->name('users.show');

An enum case and a string are different names, even when the string reads like the case: 'Home' does not find the route named Page::Home. An empty string throws an InvalidRouteException. Giving two routes the same name is not rejected when the name is set, because a route can be named after it has been added; it is rejected when the name is used to generate a URL.

Reading a route back​

A Route, such as the one $match->route holds, describes itself:

MemberReturns
$route->methodThe HttpMethod it was registered for.
$route->pathThe path as it was registered, such as /users/{id}.
$route->nameThe name given with name(), or null when it has none.
handler()The handler it was registered with.
parameters()The parameter names in the order they appear in the path, including an optional one, without the ?.
constraints()The constraints set with where() and whereMany(), keyed by parameter name.

matches() tests a path against this one route and returns its decoded parameters, or null when it does not match. It does not look at the method, and it takes the trailing slash option as its second argument, defaulting to TrailingSlash::Ignore like the router.

use Dirthara\Routing\RoutePattern;
use Dirthara\Routing\TrailingSlash;

$route = $router->get('/users/{id}', ShowUser::class)->where('id', RoutePattern::PositiveInteger);

$route->parameters(); // ['id']
$route->matches('/users/7'); // ['id' => '7']
$route->matches('/users/7/', TrailingSlash::Strict); // null

generatePath() builds this route's path from its parameters, following the same rules as generating a URL.