Routing is how Suprnova turns an inbound HTTP request into a handler call.
You declare your routes in src/routes.rs using the routes! macro (or
build a Router by hand), then Server::from_config takes that router
and runs it for the life of the process. Same shape as Laravel's
routes/web.php, with Rust types instead of facades.
// src/routes.rs
use ;
use cratecontrollers;
routes!
The macro expands to pub fn register() -> Router { ... }. Call it from
your bootstrap and hand the result to the server.
HTTP verbs
One macro per verb. All seven take a path-then-handler pair and return a
builder you can chain .name(...) and .middleware(...) onto.
| Macro | Method | Use for |
|---|---|---|
get! |
GET | Read endpoints, static pages |
post! |
POST | Create resources |
put! |
PUT | Full replacement updates |
patch! |
PATCH | Partial updates (RFC 5789) |
delete! |
DELETE | Destroy |
head! |
HEAD | Headers-only probes (HEAD falls back to the GET registry per RFC 9110 § 9.3.2 when not explicitly registered) |
query! |
QUERY | Send a read query in the request body |
options! |
OPTIONS | Capability discovery, Accept-Patch. CORS preflight is answered by CorsMiddleware before the router, so you usually don't need this one |
use ;
routes!
Every verb macro checks at compile time that the path starts with / -
a missing leading slash fails the build, not a request.
Multi-method and any!
You register QUERY with query!(path, handler) or Router::query.
You use Router::try_query when route registration can fail.
You also use query inside fluent groups and query! inside group!.
You read its body with the same request accessors you use for other methods.
You use any! to register one handler for GET, POST, PUT, PATCH, DELETE,
HEAD, OPTIONS and QUERY. You use it for endpoints that accept every supported method.
use ;
routes!
When you only want a subset of verbs sharing one handler, reach for the
builder API and Router::methods:
use Router;
use Method;
let router = new
.methods
.name
.middleware;
.name(...) and .middleware(...) fan across every verb the route was
registered against, so reverse-lookup yields the same URL whichever
method the caller looks up.
WebSocket routes
ws! registers a long-lived upgrade handler. The macro is part of the
same routes! body - covered in detail by WebSockets.
Route parameters
Dynamic segments use curly braces ({id}). For familiarity Suprnova also
accepts Express/Rails-style colons (:id) and normalises them to braces
before handing the pattern to matchit.
routes!
The colon is only treated as a parameter opener at the start of a path
segment, so literal colons mid-segment survive untouched
(/files/note:draft stays a literal route, not /files/{draft}).
Read parameters off the request inside a handler:
use ;
pub async
For typed extraction without the unwrap_or dance, see route model
binding below or #[handler] in Controllers.
Naming the parameter an argument reads
A #[handler] argument reads the route parameter named after it, so
id: i64 reads {id} and post: Post binds from {post}. A raw
identifier drops its r#: r#type: String reads {type}, a name Rust
reserves as a keyword. To read a parameter under another name, put
#[route_param("name")] on the argument, as Laravel's
#[RouteParameter('name')] does:
use ;
// GET /posts/{post}
pub async
// GET /items/{type}
pub async
#[route_param] works on a path value, an optional one and a bound
model alike. The startup checks read the renamed
parameter, so show on /posts/{id} is refused before the first
request. #[authorize] still names the argument by its own name:
#[authorize("view", id)] above.
Optional parameters
End a parameter name with ? to make the segment optional. /posts/{id?}
matches /posts and /posts/42:
routes!
On the short form the request has no id, and req.param("id") returns
Err(ParamError). Read it with req.param("id").ok() when you want an
Option. The colon spelling takes the ? too: /posts/:id?.
- Optional parameters fill from the left. In
/archive/{year?}/{month?}the router matches/archive,/archive/2026and/archive/2026/05. It never matches a month without a year. - Only optional parameters may follow an optional one.
/posts/{id?}/commentsis refused when you register it, because the router could not tell a request that leavesidout from one that fills it. So is an optional parameter that is only part of a segment (/a/pre-{x?}), and a pattern with an optional parameter and an empty segment, such as a trailing slash. - Middleware, name and constraints apply to every form. The route's
middleware runs on
/postsas it does on/posts/42. - A second route for one form of an optional route is refused.
/posts/{id?}and a separate/postsroute, or a separate/posts/{id}route, collide when you register them. - A WebSocket route takes optional parameters as well.
route(...)leaves an optional segment out when it has no value.route("archive", &[])returns/archive. A value for a later parameter with none for an earlier one is a missing parameter:try_routereturnsRouteUrlError::MissingParams.
Parameter constraints
A constraint holds a parameter to the values it may take. The router checks
it after the path has matched. A value the constraint refuses is a 404, as
if the route had not matched, and neither the route's middleware nor its
handler runs. Chain a constraint onto a route:
use ;
routes!
The same methods are on the Router builder, where they apply to the route
you just registered:
use Router;
let router = new
.get.where_number
.get
.where_pattern
.where_in;
| Method | The parameter must be |
|---|---|
where_number(param) |
One or more ASCII digits. |
where_alpha(param) |
One or more ASCII letters. |
where_alpha_numeric(param) |
One or more ASCII letters and digits. |
where_uuid(param) |
A UUID in its hyphenated form, in either case. |
where_ulid(param) |
A ULID: 26 characters of Crockford base 32. |
where_in(param, values) |
One of values, compared exactly. |
where_pattern(param, expression) |
A value that the regular expression matches from its first character to its last. |
There is no where! macro. The constraints are methods on the route.
- A pattern has to match the whole value.
where_pattern("id", "[0-9]+")refuses12a, as it does in Laravel. \dand\wmatch every script. They match the digits and letters of all scripts, where Laravel's match ASCII alone. Write[0-9]for the ASCII digits, or usewhere_number.- A constraint skips a parameter the request left out. An optional parameter is checked only when it is there.
- A constraint holds for the method you set it on. A constraint on a
GETroute does not apply to aPOSTroute with the same pattern. Anany!route and a route with several methods hold every method they cover. - A group's constraint may name a parameter of the group's prefix. In
group!("/teams/{team}", { get!("/members", h).where_number("team") }),teamis a parameter of the prefix. - A constraint on a parameter the route does not have stops the boot.
It would never be checked, and the route would look guarded while it is
not.
where_patternalso panics on an expression that is not a regular expression.
Every route builder also has .constrain(param, ParamConstraint), which the
where_* methods call. A Router route has .try_constrain(param, constraint), which returns Result<_, FrameworkError> where constrain
panics. ParamConstraint::pattern(expression) returns a Result for an
expression that is not a regular expression, and ParamConstraint::one_of
builds the list form.
Route model binding
A handler argument binds from the route parameter its name names whenever
its type implements RouteBinding. Every #[suprnova::model] struct does,
so naming the model is enough:
use ;
use cratePost;
// Route: GET /posts/{post}
pub async
#[handler] reads {post}, and the router looks the row up through
Post::query() before the handler runs. Global scopes, the soft-delete
filter, and the model's #[model(connection = "...")] apply, as they do
to every query of the model. The value is parsed as the key's type, and a
unique_id key's value must also be a well-formed identifier. A value
that does not parse and a value that matches no row both answer a 404
whose body names the model, {"message": "Post not found"}, without the
value.
The argument's name must match the placeholder (/posts/{post}, not
/posts/{id}). Primitives (id: i64, slug: String) stay path values
read through FromParam, and any other type stays a form request. Mix
them freely; one argument at most reads the request body:
// Route: PUT /posts/{post}/comments/{comment}
pub async
The router binds every bound argument, in path order, after the route's
middleware and before the handler. A missing row is a 404 before the form
is read, even with the form declared first. An Option<Post> argument
binds None when its optional parameter, {post?}, is absent.
Binding by another column
A {name:column} segment registers the parameter name and binds it by
column. req.param("post"), where_alpha("post") and route() all use
the name:
routes!
{post:slug?} is the optional form. To bind a model by another column on
every route, set its route key:
The build fails when route_key names no column of the model.
Scoped bindings
When two bound parameters follow each other, the first is the parent of
the second. A child whose segment names a column is looked up through the
parent's relation named by the child in the plural, as Str::plural forms
it, so a row the parent does not own answers 404:
// GET /users/{user}/posts/{post:slug}: only the user's own posts bind.
pub async
scope_bindings() on a route or a group scopes children without a field
too, and without_scoped_bindings() turns scoping off. A child bound by
bind() is never scoped. Every relation kind with one child type works,
BelongsToMany and HasManyThrough included; a MorphTo cannot scope a
child.
Custom resolution
A model can replace its binding. #[model(custom_route_binding)] leaves
RouteBinding to you, and the default lookup stays callable:
Any other type that implements RouteBinding binds the same way. To pass
it to route(), implement RouteValue with suprnova::bound_route_value.
A type that is a scoped parent finds its children in
resolve_child_route_binding and says so from route_binding_info()
with .with_children(ChildBindings::Custom); without that, a route that
scopes a child under it is refused at startup.
The router binds a parameter name on every route, those registered before
the call and those after. bind takes the raw value and the matched route
and wins over the type's own binding; model binds by the route key and
calls its fallback when no row matches. A - in the name reads as _:
let router = new
.bind
.
.get;
Soft-deleted rows
A soft-deleted row binds only on a route that calls with_trashed().
There, every binding goes through the soft-deletable lookups, scoped
children and the bare SeaORM form included:
let router = new
.get
.with_trashed;
Missing rows
missing(handler) on a route, a group or a resource answers instead of
the 404 when a binding finds no row, finds no row its parent owns, or gets
a value that does not parse. The handler receives the request:
let router = new
.get
.missing;
A #[handler] function works as the missing() handler too, and its
bound arguments bind under the route's settings, as the route's handler's
do. On /users/{user}/posts/{post} it can take user: User and gets the
user the route bound. A scoped child it takes without its parent is still
looked up through the parent the route's handler binds (see
Startup checks), so it never gets a row the route
refused. A bound argument of its own that finds nothing, such as the post
that missed, answers 404 instead of calling it again.
A #[handler] function that the route's handler or a middleware calls
with the request, and that returns another type than Response, cannot
return the missing() response. It fails with the 404 error instead. When
its caller, the route's handler or that middleware, returns a 404, the
handler's own or one the caller built in its place, the route answers with
the missing() response at that point, and every middleware outside the
caller sees it on its way out, as it sees the route's own missing()
response. A caller that returns anything else has recovered, and its
response stands.
Enums
A unit-only enum derives RouteBinding. Each variant binds from its
#[route(value = "...")], or else from its name in snake case, matched
exactly. Any other value answers 404, and never reaches missing():
pub async
Startup checks
#[handler] records every argument of a handler: the parameter it reads,
its type, and whether it binds, reads a path value, or reads the body.
Before the first request, the router checks every route against its
record and refuses to start, naming the route and the parameter, when:
- An argument reads a parameter the path does not declare, such as
/users/{user}withid: i64. - A binding field names no column of the model, or a column whose type cannot be parsed from a path segment.
- A scoped child needs a relation the parent does not declare, one of
another type, or a
MorphTo. - A binder returns another type than the argument it binds, or covers a parameter the handler reads without binding.
- A handler reads the request body twice.
The fallback and every missing() handler are checked too, each against
the path of the route it answers. Server::from_config returns the
refusal as an error. A router driven through handle_request in a test
runs the same checks before its first request and answers every request
with a 500 when they fail, a request the fallback would answer included;
router.prepare_bindings() returns the error itself. A closure handler
and a generic #[handler] function carry no record and are not checked
at startup. A closure binds nothing itself. A generic handler, a
missing() handler, and a #[handler] function that a closure route,
middleware or another handler calls with the request, bind their
arguments of a concrete type that implements RouteBinding as any
handler's do: in path order, by the route's binding fields, scoped,
through the router's binders, with with_trashed() and missing(). The
route plans them at the handler's first request and keeps the plan. A
binding field, a scoped child, or a binder that covers a parameter the
handler reads without binding, which the checks above would refuse,
answers that request with a 500 instead of binding. A generic handler's
generic arguments read the body.
When such a handler takes a scoped child without its parent, the child is still found through the parent, bound the way the route's own handler binds it:
- When the route's handler is a recorded
#[handler]or a generic one that binds the parent, the child is looked up through that parent, so a row the parent does not own answers 404. - When the route's handler binds no parent (a closure, a function that is
not a
#[handler], or a handler that takes the child alone), nothing scopes the child, as for any handler whose parent is not bound. - When the router cannot see how the route's handler binds the parent (a
generic
#[handler]it cannot find by its path, such as one declared inside a function, one that binds the parent asRouteParam<T>withTits type parameter, or a function pointer), the request answers a 500 that names the route, the handler, the child and the parent, and says to take the parent too. It never looks the child up unscoped.
A handler inside an impl block names its type,
#[handler(Self = Posts)], and is checked as a free handler is; see
Handlers inside an impl block.
Older binding forms
Two older forms keep working. RouteParam<Post> binds as Post does. A
SeaORM row whose entity implements EntityExt binds by its primary key
through EntityExt::find_by_pk, with no global scope and on the default
connection; its 404 names the entity's module:
pub async
Bind the #[model] struct in new code: its lookup applies the model's
scopes and connection.
Binding is identity, not authorization
Route model binding answers "does this row exist?" - it does not
answer "is the current user allowed to see this row?". A bare bound
handler lets any authenticated user view any post by guessing
/posts/N. Authorize against the bound model with
#[authorize("view", post)] or Gate::authorize - see
Authorization.
Opting out
Take the key as a path value and query yourself:
use ;
use crateUser;
pub async
Why Suprnova diverges
- A value is parsed before the query. Laravel passes the raw value to
the query, so MySQL reads
7abcas7and binds row 7, and Postgres raises an error. Here a value that does not parse as the column's type, or breaks aunique_idkey's format, answers the same 404 a missing row does. - The 404 never repeats the value. Laravel's message carries the requested value; Suprnova's names the model alone.
- An enum binds by name. Rust enums carry no backing value, so a
variant without
#[route(value = "...")]binds from its name in snake case. - A renamed argument names its parameter with an attribute. Laravel's
#[RouteParameter('name')]reads the parameter's value into an argument;#[route_param("name")]does the same and also binds a model from it, and the startup checks read the name it gives. - Mistakes stop the server at startup. Laravel finds a missing relationship at request time, as a 500, and injects an empty model for an argument the route does not declare. Suprnova refuses those routes, and the other mistakes the startup checks list, before the first request.
Named routes
Names give you stable identifiers for URL generation. Attach one with
.name(...):
routes!
Names follow the Laravel convention <resource>.<action> -
users.show, posts.destroy, admin.dashboard. Look them up with the
top-level route(name, &[...]) helper:
use route;
let home = route;
// Some("/")
let profile = route;
// Some("/users/123")
// A bound value fills its parameter with its route key, or with the
// column a `{post:slug}` parameter names.
let post_url = route;
// Some("/posts/hello-world")
route returns Option<String> and percent-encodes parameter values
into path-safe form (so ("slug", "a/b") becomes /posts/a%2Fb -
matchit-safe and round-trips through req.param("slug")). A catch-all
segment such as /files/{*rest} takes its value under the name the
handler reads it by, rest, and keeps its slashes:
route("files.show", &[("rest", "docs/a b.txt")]) returns
/files/docs/a%20b.txt. For redirect
targets and email links use the strict sibling suprnova::routing::try_route,
which returns Result<String, RouteUrlError> and refuses to emit a URL
containing an unfilled {placeholder} segment. See
URL Generation for the full URL surface (signed URLs,
absolute URLs, Redirect::route).
Route names are globally unique and process-global. Registering the same
name to two different paths panics at boot - silent shadowing was a
security-shaped bug because redirects would route to whichever
registration happened to win. Use RouteBuilder::try_name (or
suprnova::routing::try_register_route_name) for the fallible variant.
route_has(&[...]) tells you whether names are registered, as Laravel's
Route::has does. It is true only when every name in the list is:
use route_has;
if route_has
One unregistered name makes it false. An empty list is true.
Per-route middleware
Chain .middleware(M) on any route builder:
use ;
use crate;
routes!
.middleware_named("auth") adds the middleware that a registered alias
stands for. .middleware_named("throttle:60,1") gives the alias its
arguments, and a name can stand for a group of middleware. See
Middleware.
Route-local middleware runs after any global middleware
(Server::with_middleware) and any group middleware that wraps the
route. The middleware map is keyed by (method, path), so attaching
auth to POST /api/posts never bleeds onto a public GET /api/posts
on the same path. For the middleware contract and writing your own, see
Middleware.
A route leaves out a middleware its group gives it with
.without_middleware::<M>() or .without_middleware_named("auth"), as
Laravel's withoutMiddleware does. The global middleware stays. See
Leaving middleware out.
Route groups
group! factors out a shared path prefix and/or shared middleware:
use ;
use crate;
routes!
A group prefix is concatenated with each route path. A route at /
inside a group resolves to the group prefix exactly
(group!("/users", { get!("/", index) }) → GET /users).
Group name prefix
.name(prefix) puts a prefix in front of the name of every route in the
group. Mirrors Laravel's Route::name('admin.')->group(...). Each route
then names itself by the part the group leaves out:
routes!
The route is known by its full name, route("admin.users.index", &[]), and
not by index. Three rules:
- The prefix is used as you write it. End it with the separator your names use, here the dot.
- A group inside adds its own prefix after this one. With
.name("admin.")outside and.name("users.")inside, a route namedindexisadmin.users.index. A group with no prefix of its own passes the outer one on. - A route without a name stays without one. The prefix names nothing by itself.
The name prefix is on group!. The Router::group(...) builder has no
name prefix.
Group controller
A group can name the module its handlers live in. Write controller = after
the path prefix, and a route names its handler by function alone:
routes!
This is the same as writing controllers::admin::users::index in each
route. The path prefix stays the first argument of the macro. Two rules:
- The controller applies to a handler written as one bare name. A
handler written as a path,
get!("/health", controllers::health::check), is used as written. That lets a route reach a handler in another module. - A group inside names its own controller. A nested
group!is added as written, so it takes its owncontroller =, or none.
.name(...) and .middleware(...) chain onto the group in the same way
with or without controller =.
Nested groups
Groups nest to any depth. Prefixes concatenate; middleware inherits from parent to child:
routes!
| Route | Effective path | Middleware chain |
|---|---|---|
/api/health |
/api/health |
AuthMiddleware |
/api/v1/users |
/api/v1/users |
AuthMiddleware |
/api/v1/admin/stats |
/api/v1/admin/stats |
AuthMiddleware → AdminMiddleware |
For a single route inside a nested group, the execution order is
outermost middleware first: parent group → child group → route-local.
Per-route .middleware(...) runs innermost.
Fallback route
fallback! registers a handler that runs when no other route matches.
Use it for custom 404 pages.
use ;
routes!
// src/controllers/errors.rs
use ;
pub async
Fallback supports its own middleware chain (fallback!(handler).middleware(M)).
If no fallback is registered, the framework returns a plain-text
404 Not Found.
When only the method is wrong
A path that a route of another method matches is not a 404. As Laravel
does, the router checks the other methods (GET, HEAD, POST, PUT,
PATCH, DELETE, OPTIONS, QUERY) and answers with the ones that
match:
routes!
| Request | Answer |
|---|---|
DELETE /posts/1 |
405, Allow: GET, HEAD, and the error body {"message": "The DELETE method is not supported for route posts/1. Supported methods: GET, HEAD.", "request_id": "..."} |
OPTIONS /posts/1 |
200 with an empty body and Allow: GET,HEAD |
DELETE /nothing/here |
the fallback, or the 404 |
Allow lists the methods in the order above. HEAD stands beside GET
because a GET route answers HEAD. A route whose
constraints refuse the path is not counted, so
DELETE /posts/abc is still a 404 when {id} must be a number. The
fallback only answers a path that no route of any method matches.
Both answers run through the global middleware, as the 404 does. The
CORS middleware therefore still answers a preflight OPTIONS request
before the router's 200 is reached.
Resource routing
For a standard 7-action REST surface, implement ResourceController and
register the resource through the Router builder, or name a module of
#[handler] functions with resource! (see Actions that bind).
Laravel parity for Route::resource() and Route::apiResource().
use ;
use Pin;
use Future;
;
let router: Router = new
.resource
.into;
Methods you don't override return 404. Use api_resource to drop
create and edit - the two routes that exist only to render forms.
Default routes and names
| Verb | Path | Trait method | Name |
|---|---|---|---|
| GET | /posts |
index |
posts.index |
| GET | /posts/create |
create |
posts.create |
| POST | /posts |
store |
posts.store |
| GET | /posts/{post} |
show |
posts.show |
| GET | /posts/{post}/edit |
edit |
posts.edit |
| PUT | /posts/{post} |
update |
posts.update |
| DELETE | /posts/{post} |
destroy |
posts.destroy |
The path parameter defaults to the singular of the resource name -
posts → {post}, categories → {category}. Irregular plurals get
the literal last segment; override with .parameter(...).
Restricting and renaming
use ;
new
.resource
.only // pin to two verbs
.names // rename a default
.parameter // {post} → {post_id}
.into;
Rust-side aliases that read better in some call sites: .keep(...) for
.only(...), .drop(...) for .except(...), .rename(...) for
.names(...).
Actions that bind
A ResourceController action takes the request. For actions that take
bound arguments, name a module of #[handler] functions with resource!
instead; each action is the function of its name:
use ;
// controllers::posts::{index, create, store, show, edit, update, destroy}
routes!
// src/controllers/posts.rs
pub async
only = [...] and except = [...] go inside the macro: an action the
selection keeps whose function the module does not define fails to
compile, and an action it leaves out needs no function. The builder takes
.names(...), .parameter(...), .parameters(...), .unnamed() and
.authorize_resource::<U, R>() as the controller form does.
Nested resources
A dotted name nests one resource in another. users.posts registers
/users/{user}/posts and /users/{user}/posts/{post}, named
users.posts.index through users.posts.destroy:
routes!
parameters renames a segment's parameter. scoped gives parameters a
binding field and scopes every nested parameter to its parent; an empty
list scopes without fields. with_trashed binds soft-deleted rows on the
actions it names, show, edit and update when it names none.
missing answers for every route of the resource when a binding finds
nothing. The controller form takes the same four.
Controller middleware
A controller declares the middleware its actions run: a ResourceController
in its middleware method, and a module named by resource! in a
pub fn middleware(). See
Controller middleware.
.middleware(ControllerMiddleware) on the registration scopes one more the
same way, with only and except. It is Laravel's middlewareFor:
use ;
new
.resource
.middleware
.middleware
.into;
resource! takes the same .middleware(...). On every route of the
resource the middleware runs in this order:
- the middleware of the group around the resource;
- the middleware given with
.middleware(...)here; - the middleware the controller declares;
- the check of
authorize_resource, which needs the user the middleware before it authenticated.
A name that no alias or group carries fails the registration:
try_register returns the error, and register panics at boot.
Resources inside a group
resource! is an item of group!, as Laravel's Route::resource is
inside Route::group. The resource takes the group's prefix, name prefix,
middleware, session block and binding settings:
routes!
This registers /admin/posts through /admin/posts/{post}/edit, named
admin.posts.index through admin.posts.destroy, each running auth
before the controller's own middleware. The Router::group(...) builder
and the controller form, Router::resource, register no resources inside
a group.
Bulk registration
new
.resources
.api_resources;
Authorizing the whole resource
authorize_resource::<U, R>() attaches the conventional ability check to
every generated route as per-route middleware - Laravel's
authorizeResource parity. Without it, a resource surface is ungated
unless every controller body remembers to call Gate::authorize; a single
forgotten destroy ships an ungated delete.
use ;
// Abilities are keyed on (ability, user type, resource marker type).
;
;
;
;
let router: Router = new
.resource
.
.into;
The action → ability mapping mirrors Laravel:
| Action(s) | Ability |
|---|---|
index, show |
view |
create, store |
create |
edit, update |
update |
destroy |
delete |
PATCH shares the update action, so it is gated identically to PUT. A
denied ability short-circuits with 403 before the handler runs, and an
unauthenticated request fails closed. The resource marker R only needs
Default - the gate discriminates on its type, the way Laravel
discriminates on the model class. See the authorization chapter
for defining the abilities themselves.
Router-level redirects and views
Three sugar methods on Router cover route declarations that don't need
a handler function:
use Router;
use json;
let router = new
// Static redirect: GET /old-pricing → 302 /pricing
.redirect
// 301 sibling
.permanent_redirect
// Inertia static page: GET /about renders the About component
.inertia
.name;
Router::inertia is Suprnova's Route::inertia($uri, $component, $props). It registers GET; a HEAD request falls through to it and
has its body stripped at the server boundary, so there's nothing extra
to register. It returns a RouteBuilder, so .name(...) and
.middleware(...) chain off it like any other route.
Props must be a JSON object, or null for none. Anything else -
an array, a string - is a registration error, not a silently empty prop
bag. try_inertia is the fallible form.
Router::view is the same method under its older name; it returns
Router rather than RouteBuilder, so a route declared with it can't
be named. Prefer inertia.
Why Suprnova diverges
Laravel's Route::view renders a Blade template; Suprnova renders an
Inertia component, because the framework's templating system is Inertia,
not Blade. One consequence: the component name is a runtime string here,
so it doesn't get the compile-time page-component check that the
inertia_response! macro performs. Write the handler out with
inertia_response! when you want a typo in a component name to fail the
build rather than the request.
For redirect responses (not route declarations) - Redirect::route,
Redirect::back, Redirect::intended, signed redirects - see
URL Generation and Responses.
Signed URLs
HMAC-signed routes are routing-adjacent (you mint a URL against a named route, then verify the signature on the inbound request). They're covered in full by URL Generation; the short version:
use url;
let reset = signed_route?;
// /password/reset/42?signature=...
let expires_at = now.timestamp + 3600;
let verify = temporary_signed_route?;
// /verify/email/42?expires=1748803600&signature=...
Verify inside a handler with url::has_valid_signature(&request) (boolean)
or url::signature_verdict(&request) (the three-way
Valid/Expired/Invalid split, so you can render a "request a fresh
link" page instead of a generic 403).
Fallible registration
Route registration runs once at boot, so a duplicate or malformed route
is treated as a programmer error: the plain helpers (Router::get,
post, put, delete, ws, RouteBuilder::name, the
GroupBuilder → Router From conversion) panic to fail loudly at
startup. That's the right default for routes declared in source.
When patterns or names come from a fallible source - dynamic config, a
plugin system, a test that deliberately registers conflicting routes -
use the try_* siblings. They return Result<_, FrameworkError>
(naming the offending method, path, or conflicting name) instead of
panicking:
| Panicking | Fallible sibling | Returns |
|---|---|---|
Router::get / post / put / patch / delete / head / options |
try_get / try_post / try_put / try_patch / try_delete / try_head / try_options |
Result<RouteBuilder, FrameworkError> |
Router::ws (and every ws_* variant) |
try_ws (and every try_ws_*) |
Result<Router, FrameworkError> |
RouteBuilder::name |
try_name |
Result<Router, FrameworkError> |
GroupBuilder → Router via .into() |
GroupBuilder::try_finalize |
Result<Router, FrameworkError> |
ResourceRoutes::register |
try_register |
Result<Router, FrameworkError> |
use ;
// `path` comes from dynamic config; a malformed or duplicate pattern
// is recoverable, not a startup panic.
A duplicate group route is recoverable the same way - because From
cannot be fallible, the fallible counterpart of .into() is the
inherent try_finalize method:
let router: Router = new
.group
.try_finalize?;
The panicking helpers stay as ergonomic escape hatches; the try_*
siblings are purely additive.
Why Suprnova diverges
Dual path-parameter syntax. Laravel uses {param}; Express uses
:param. Suprnova accepts both and normalises :param to {param}
before the path reaches matchit. Both styles compose with everything
else - groups, model binding, signed URLs. The reason isn't
indecisiveness; it's that we can't predict which background you bring,
and routing syntax is too high-frequency a friction point to make people
relearn.
Two co-equal APIs: macro and builder. Laravel ships one DSL
(Route::get(...)). Suprnova ships the declarative routes! { ... }
macro AND the chainable Router::new().get(...).name(...) builder.
They produce identical registrations. The macro reads better for
top-level route tables; the builder reads better when you're composing
routers dynamically (plugins, generated routes, tests). Pick whichever
fits the call site - there's no canonical answer because both shapes
are first-class.
The fallback answers every method. Laravel registers its fallback as
a GET route that matches any path, so a POST to an unknown path there
is a 405 that lists GET, HEAD. Suprnova's fallback is not a route of
any method: it answers a path no route matches, whatever the method, and
the 405 is for paths your own routes match.
Boot-time panics, not silent shadowing. A duplicate route name or
pattern collision panics at startup. Laravel's array-keyed registries
silently let the later registration win, which is fine when your routes
file is the only registrar but unsafe once plugins or generated routes
enter the picture. try_* siblings are the escape hatch when fallibility
is what you actually want.
Next
- Controllers -
#[handler], form requests, returning JSON/Inertia - Middleware - the
Middlewaretrait, ordering, building your own - URL Generation - named-route URLs, signed URLs, redirects,
RouteUrlError - Authorization - gates and policies for bound models
- WebSockets -
ws!, theWebSocketHandlertrait, per-route config
