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.
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 case | Pattern | Matches |
|---|---|---|
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. |
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.
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:
| Member | Returns |
|---|---|
$route->method | The HttpMethod it was registered for. |
$route->path | The path as it was registered, such as /users/{id}. |
$route->name | The 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.