Manual contentsThe BasicsBrowse 126 chapters
Manual 24 min read

URL Generation

URLs are how your app references itself - every redirect, every email link, every Inertia <Link> href, every signed download has to come from somewhere. Hard-coding paths makes refactors painful and route renames unsafe. Suprnova ships a small url:: namespace and a sibling route() helper that take a name plus parameters and give you back a string, with percent-encoding handled, signature minting available, and verification that matches Laravel's wire format byte-for-byte.

This chapter is the reference for the URL-generation surface. The Routing chapter covers how to declare routes and name them; this one covers what you do with those names afterwards.

use suprnova::{route, url};

// Lookup by name → URL
let profile = route("users.show", &[("id", "42")]).unwrap();
//   "/users/42"

// Absolute URL against APP_URL
let absolute = url::to("/dashboard");
//   "https://app.test/dashboard"

// Signed link for password reset
let link = url::signed_route("password.reset", &[("token", reset_token)])?;
//   "/password/reset/xyz?signature=ab12..."

// Verify on the inbound request
if url::has_valid_signature(&request)? {
    // act on it
}

Everything in this chapter is re-exported under suprnova::url::* and suprnova::route so consumer code never has to reach into the routing module directly.

Named routes

A name is a string label attached to a route at registration time. Once a name exists, route(name, params) resolves it back to a URL pattern and substitutes the parameters. Names live in a single process-global registry - there is one name → path table per running binary, not one per Router.

use suprnova::{routes, get, post};

routes! {
    get!("/", controllers::home::index).name("home"),
    get!("/users/{id}", controllers::users::show).name("users.show"),
    post!("/users", controllers::users::store).name("users.store"),
}

The .name(...) call registers "users.show" → "/users/{id}". From that point on, anywhere in the process can resolve the name:

use suprnova::route;

let url = route("users.show", &[("id", "42")]);
// Some("/users/42")

let missing = route("does.not.exist", &[]);
// None

Behind a reverse proxy that serves the application under a path prefix, route() puts the public root in front of the path: under X-Forwarded-Prefix: /billing from a trusted proxy, the call above returns Some("/billing/users/42"). At the host root the output is unchanged. A redirect or URL builder given this output keeps the root once. For the proxy setup, see Serving under a path prefix.

Re-registering the same (name, path) pair is idempotent - useful when route registration runs more than once during boot. Registering a name under a different path panics; that collision is a security-shaped bug because helpers like Redirect::route would silently target whichever side won the race.

Bound values

A parameter takes a string, a number, or a bound value. A bound value is a value whose type implements RouteValue: a #[model] row, a #[derive(RouteBinding)] enum, and a RouteParam<T> do. A bound value fills a {post} parameter with its route key and a {post:slug} parameter with its slug column, encoded as any value is:

use suprnova::route;

// get!("/posts/{post:slug}", ...).name("posts.show")
let url = route("posts.show", &post);
// Some("/posts/hello-world")

// One value fills the route's first parameter. Name the values to fill
// several, mixing strings and bound values:
let url = route("users.posts.show", (("user", &user), ("post", &post)));
// Some("/users/7/posts/hello-world")
let url = route("users.posts.show", (("user", "7"), ("post", "hello-world")));

A type that implements RouteBinding by hand, and a bare SeaORM model whose entity implements EntityExt, become bound values with one call to bound_route_value:

impl suprnova::RouteValue for Region {
    fn route_value(&self, field: Option<&str>) -> Option<String> {
        suprnova::bound_route_value(self, field)
    }
}

Every call written with &[("name", "value")] pairs keeps working and builds the same URL, except the forms below. They compiled only because the parameter used to be a &[(&str, &str)] slice, which set the type of the pairs or converted them to it:

  • A name or value whose type the parameter inferred: s.as_ref(), s.borrow(), Default::default().
  • A name or value that reaches &str through more than one dereference, or through a type of the application's own: &&&str, &Box<String>.
  • A container of pairs reached the same way: &&&Vec<_>, &Box<Vec<_>>, a SmallVec.
  • An array whose pairs mix &String and &str values, such as &[("id", &id), ("tab", "posts")].
  • An array of more than 32 pairs that holds &String values.
  • route used as a fn(&str, &[(&str, &str)]) -> Option<String> pointer.

Write a name or value with .as_str() or &*, and a container with .as_slice() or &*. In place of the pointer, write a closure:

use suprnova::route;

let url = route("users.show", &[("id", id.as_str()), ("tab", "posts")]);
// `boxed_id` is a `Box<String>`, `pairs` a `SmallVec<[(&str, &str); 4]>`.
let url = route("users.show", &[("id", &*boxed_id)]);
let url = route("users.show", pairs.as_slice());

let build: fn(&str, &[(&str, &str)]) -> Option<String> =
    |name, params| route(name, params);

The public root

url::root() returns the public root on its own: /billing behind a trusted X-Forwarded-Prefix: /billing, the path in APP_URL when no trusted prefix arrives, and the empty string at the host root, never with a trailing slash. It is the way a template writes a root-relative link that route() does not build, such as a component stylesheet:

<link rel="stylesheet" href="{{ suprnova::url::root() }}/suprnova-ui/button/button.css">

The root followed by a path that starts with / is a link that works at every root. url::root() carries no capability, so a third-party component's view may call it too, in exactly this form.

The lookup helpers

Function Returns When the route is missing
route(name, params) Option<String> None
route_with_params(name, params_map) Option<String> None
try_route(name, params) Result<String, RouteUrlError> Err(NameNotFound)
try_route_with_params(name, params_map) Result<String, RouteUrlError> Err(NameNotFound)

The lenient route / route_with_params pair leaves any unfilled {placeholder} segment verbatim in the output - fine for debug logs, unsafe to ship to a browser. The strict try_route / try_route_with_params pair returns RouteUrlError::MissingParams { name, missing } listing the unfilled placeholders so the caller can fail loudly instead of redirecting a user to /users/{id}.

use suprnova::routing::{try_route, RouteUrlError};

match try_route("users.show", &[]) {
    Ok(url) => /* safe to redirect */,
    Err(RouteUrlError::MissingParams { name, missing }) => {
        // missing == vec!["id"]
        return Err(FrameworkError::internal(
            format!("cannot build URL for {name}: missing {missing:?}"),
        ));
    }
    Err(RouteUrlError::NameNotFound(name)) => {
        return Err(FrameworkError::internal(format!("unknown route: {name}")));
    }
}

Redirect::route uses try_route_with_params under the hood for exactly this reason - a redirect with a raw {id} in the Location header would be worse than failing.

Percent-encoding is automatic

Parameter values are encoded per RFC 3986 path-segment rules before they are substituted in. That covers the gen-delims and sub-delims (/ ? # [ ] @ ! $ & ' ( ) * + , ; =), control characters, space, and % itself. Unreserved characters (A-Z a-z 0-9 - _ . ~) pass through unchanged.

use suprnova::route;

// A slug containing a slash is contained in one segment:
route("posts.show", &[("slug", "hello/world")]);
// Some("/posts/hello%2Fworld")

// Path traversal attempts can't escape the segment:
route("users.show", &[("id", "../../etc/passwd")]);
// Some("/users/..%2F..%2Fetc%2Fpasswd")

// Real Unicode passes through untouched:
route("users.show", &[("id", "user-é-42")]);
// Some("/users/user-%C3%A9-42")

The matching side preserves this round-trip - a request to /posts/hello%2Fworld matches the /posts/{slug} route and a handler reading req.param("slug") sees "hello/world", decoded. Encode at the boundary, decode at the boundary; never see the raw bytes in handler code.

Reverse lookup

When you have a matched route pattern and want the registered name - e.g. for logging or for Request::route_is("users.show") checks - use route_name_for_pattern:

use suprnova::routing::route_name_for_pattern;

let name = route_name_for_pattern("/users/{id}");
// Some("users.show")

This is an O(n) scan over the name registry. n is the number of registered names; even at four-digit route counts the cost is negligible compared to the surrounding request lifecycle. The function is exposed for tooling and middleware - Request::route_is already calls it for you when you compare against a named route in a handler.

Absolute URLs

For everything else - building emails, sharing URLs, sending Open Graph metadata - you want an absolute URL with the right scheme and host. url::to joins a path to APP_URL:

use suprnova::url;

// In env: APP_URL=https://app.example.com
let url = url::to("/about");
// "https://app.example.com/about"

// Already-absolute URLs pass through unchanged:
let cdn = url::to("https://cdn.example/asset.js");
// "https://cdn.example/asset.js"

let proto_relative = url::to("//cdn.example/asset.js");
// "//cdn.example/asset.js"

The host, scheme, and port all come from APP_URL. If APP_URL is http://localhost:8765, then url::to("/foo") yields "http://localhost:8765/foo". The trailing slash on APP_URL is normalised away so you never end up with https://host//path.

The path between the host and your path is the public root: the path in APP_URL, or a trusted X-Forwarded-Prefix of the request being handled, which replaces it. With APP_URL=https://example.org/billing, url::to("/foo") yields "https://example.org/billing/foo", and url::to("/billing/foo") yields the same URL, because a path that already carries the root keeps it once.

Forcing HTTPS

url::secure(path) builds the same absolute URL but upgrades the scheme to https:// even if APP_URL is http://:

use suprnova::url;

// In env: APP_URL=http://app.example.com
url::secure("/login");
// "https://app.example.com/login"

In production you typically set APP_URL to your HTTPS host once and never call secure directly - the upgrade is for environments where local development runs over HTTP but a specific link must be HTTPS (e.g. a callback URL embedded in a payment session).

url::secure_with(path, segments) appends each segment to the path before the upgrade, as Laravel's url()->secure($path, $parameters) does. Each segment is percent-encoded as one path segment, with the encoding route() uses for a parameter, so a value with a space or a slash stays one segment:

use suprnova::url;

// In env: APP_URL=http://app.example.com
url::secure_with("users", &["a b", "7"]);
// "https://app.example.com/users/a%20b/7"

url::secure_with("/files", &["2026/report.pdf"]);
// "https://app.example.com/files/2026%2Freport.pdf"

The segments go before any query or fragment in path. Laravel drops the segments when path is already an absolute URL; secure_with appends them there too.

Reading the current URL

Inside a handler, the request itself is the source of truth:

use suprnova::url;

// In env: APP_URL=https://app.test, request: GET /posts/42?expand=author
async fn breadcrumbs(req: Request) -> Response {
    let here = url::current(&req);       // "https://app.test/posts/42"
    let full = url::full(&req);          // "https://app.test/posts/42?expand=author"
    let back = url::previous("/");       // session-recorded previous URL
    let from = url::previous_path("/");  // "/posts" after a previous "/posts/?page=2"
    // ...
}
Helper Returns Source
url::current(&req) absolute URL of this request, without the query APP_URL origin + public root + path
url::full(&req) absolute URL of this request, with the query APP_URL origin + public root + path + query, its pairs sorted by key and re-encoded (/posts?b=2&a=1 gives ?a=1&b=2)
url::previous(fallback) previous URL recorded by the session middleware _previous.url in the session, or fallback
url::previous_path(fallback) the path of previous(fallback) previous(fallback) without query, root or trailing slash

current and full take the scheme, host and port from APP_URL, never from the Host header, so a client cannot change the origin they return. Both carry the public root: behind a trusted X-Forwarded-Prefix: /billing, current for /invoices returns https://app.test/billing/invoices.

previous_path is the value to compare with a route's path, because a route's path carries neither the root nor the query. Under the root /billing, a previous URL of /billing/invoices/?page=2 gives /invoices. When nothing is left, such as for the root itself, you get /. With no previous URL recorded, the same is done to fallback.

previous is what backs Redirect::back - the session middleware records the URL of every successful HTML GET so a form POST can bounce back to the page that submitted it. Inertia partials, JSON-API requests (Accept: application/json without text/html), and non-2xx/3xx responses are skipped so you never bounce back to an intermediate endpoint the user never saw. The middleware also refuses to record a URL that isn't root-relative and same-origin: a request path shaped like //host or /\host (both of which a browser reads as protocol-relative, not a path) or carrying an ASCII control byte anywhere in it (a TAB or newline that a browser's URL parser strips before comparing origins, turning what looks like a safe path into one of the two forms above) is never stored - and the same check runs again on every read, so a value stored by an older release keeps failing it too, rather than being trusted only because it's already in the session. Either way, previous and Redirect::back can't be steered off-origin by an unusual request path reaching your app, past or present.

Signed URLs

Signed URLs let you mint a URL that proves it came from your server, without storing the URL anywhere. The signature is HMAC-SHA256 over the canonical form of the URL using your APP_KEY; the server recomputes the HMAC on the inbound request and accepts only matching signatures.

Reach for signed URLs when:

  • Email-delivered links - password reset, email verification, invite-by-email, magic-link login. The URL has to survive a round trip through an inbox without being storable as opaque state.
  • Ephemeral downloads - "your CSV export is ready" links that expire in 24 hours, signed S3 alternatives where you want the URL to remain on your domain.
  • Webhooks pointing back at you - third-party callbacks that should refuse forged calls without requiring a database lookup per request.
use suprnova::url;
use chrono::Utc;

// Permanent signed URL - never expires.
let link = url::signed_route(
    "password.reset",
    &[("user", user_id), ("token", token)],
)?;
// "/password/reset/42/xyz?signature=ab12cd34..."

// Temporary signed URL - expires one hour from now.
let expires_at = Utc::now().timestamp() + 3600;
let link = url::temporary_signed_route(
    "verify.email",
    &[("user", user_id)],
    expires_at,
)?;
// "/verify/email/42?expires=1748803600&signature=def012..."

Note that expires_at_epoch_seconds is an absolute UNIX timestamp, not a duration. Compute it at the call site:

let one_hour_from_now = chrono::Utc::now().timestamp() + 3600;
let one_day_from_now  = chrono::Utc::now().timestamp() + 86_400;

That keeps the helper signature small and lets you reuse the same function for both relative-from-now and explicit-absolute deadlines.

Verifying

On the inbound side, you verify the signature against the live request:

use suprnova::{url, AppError, FrameworkError, Request, Response, HttpResponse};

pub async fn reset(req: Request) -> Response {
    reset_inner(req).await.map_err(HttpResponse::from)
}

async fn reset_inner(req: Request) -> Result<HttpResponse, FrameworkError> {
    if !url::has_valid_signature(&req)? {
        return Err(AppError::forbidden("Invalid or expired link").into());
    }
    // Signature is good and not expired - proceed.
    let user_id = req.param("user").unwrap();
    // ...
    Ok(HttpResponse::text("ok"))
}

The signature covers the public root as well as the path, because the browser sends both back: a link signed behind a /billing prefix verifies behind it, and not under another root or at the host root. Moving an application under a prefix therefore ends the links signed before the move.

has_valid_signature returns true only when the HMAC matches AND the URL is not expired. For the three-way distinction between invalid, expired, and valid, use signature_verdict:

use suprnova::{url, AppError, FrameworkError, HttpResponse, Request, Response};
use suprnova::routing::SignatureVerdict;

pub async fn reset(req: Request) -> Response {
    reset_inner(req).await.map_err(HttpResponse::from)
}

async fn reset_inner(req: Request) -> Result<HttpResponse, FrameworkError> {
    match url::signature_verdict(&req)? {
        SignatureVerdict::Valid => {
            // Proceed.
        }
        SignatureVerdict::Expired => {
            // Bounce the user to a page that explains the link expired
            // and offers to send a fresh one.
            return Ok(HttpResponse::new()
                .status(302)
                .header("Location", "/password/reset-expired"));
        }
        SignatureVerdict::Invalid => {
            // Render a generic 403 - don't leak whether the signature
            // was malformed, missing, or just wrong.
            return Err(AppError::forbidden("Invalid link").into());
        }
    }
    // ...
    Ok(HttpResponse::text("ok"))
}

signature_has_not_expired(&req) is deprecated and now answers exactly what has_valid_signature answers. Reach for signature_verdict above instead; a URL with no expires query parameter is "never expired" by definition, in Suprnova as in Laravel.

Protecting a route with the signature middleware

ValidateSignature puts the check on the route, so a handler cannot forget it. A request with a valid signature reaches the handler. Every other request - a missing, wrong, or expired signature alike - gets 403 with the message Invalid signature. in the framework's usual JSON error body, as Laravel's InvalidSignatureException answers:

use suprnova::routing::ValidateSignature;
use suprnova::Router;

let router = Router::new()
    .get("/verify/email/{user}", controllers::verify::email)
    .middleware(ValidateSignature::new())
    .name("verify.email");

To name it as Laravel does, register the signed alias once at boot. signed:relative reads relative first, then the parameters to ignore:

use suprnova::middleware::register_middleware_alias_with_args;
use suprnova::routing::ValidateSignature;

register_middleware_alias_with_args("signed", ValidateSignature::from_alias_args);

// .middleware_named("signed")
// .middleware_named("signed:relative,utm_source")

Suprnova signs the public root, the path and the sorted query of every URL, never the scheme or the host. ValidateSignature::new() and ValidateSignature::relative(ignore) therefore verify the same text; relative exists so a route written for Laravel's signed:relative keeps its meaning. One answer for all three failures keeps the response from telling a prober what failed. When you want to tell an expired link from a forged one, match on signature_verdict in the handler instead.

Ignoring parameters added after signing

A mail client or a campaign tool can append a parameter, such as utm_source, to a signed link. That parameter is not part of the text the signature covers, so the link stops verifying. Name the parameters to leave out of the verified text:

use suprnova::routing::ValidateSignature;
use suprnova::url;

// On one route.
let middleware = ValidateSignature::new().ignore(["utm_source"]);

// For every ValidateSignature in the application, at boot.
ValidateSignature::except(["utm_campaign"]);

// In a handler, without the middleware.
if url::has_valid_signature_ignoring(&req, &["utm_source"])? {
    // act on it
}

Every pair of an ignored parameter is left out, repeats included. The path, the root and every other parameter stay covered, so a changed signed value still fails. signature and expires cannot be ignored: a repeated signature or expires is still refused, and the expiry is still checked. Treat the value of an ignored parameter as unsigned client input. ValidateSignature::except applies to the middleware only, as Laravel's $neverValidate does; has_valid_signature_ignoring leaves out exactly the names you pass.

Why Suprnova diverges

Laravel's URL::signatureHasNotExpired($request) is literally "not expired", so a forged signature comes back true - it never had an expiry to miss. Suprnova's used to match that. It doesn't any more: the helper requires a valid signature first.

The reason is that expires is attacker-supplied until the HMAC says otherwise, so no answer derived from it means anything before the signature checks out - and a function whose name reads like a guard was letting every forged URL through anything that called it alone.

Requiring validity collapses it into has_valid_signature, which is why it carries a deprecation rather than a behaviour flag. That collapse is not a loss: under a three-state verdict there is no "not expired" a single bool can report honestly except Valid. If you want to tell expired from invalid - to say "request a fresh link" instead of "forbidden" - that is what signature_verdict is for, and it says it in the type.

Signing arbitrary URLs

If the URL you want to sign doesn't come from a registered named route - a callback URL handed to you by a third party, a path constructed dynamically at runtime - use signed_url directly:

use suprnova::url;

let callback = url::signed_url(
    "/webhooks/stripe/callback?order=42",
    Some(chrono::Utc::now().timestamp() + 600),  // 10-minute expiry
)?;

Pass None for the expiration to mint a permanent signature. The verify side is the same - has_valid_signature(&req) doesn't care whether the URL was minted from a named route or from a raw path.

Wire format

Two URLs that differ only by the order of their query parameters produce identical signatures because the canonical form sorts query pairs by key before hashing. That matters because clients sometimes reorder query parameters in transit (proxies, link previewers, mobile email apps), and a signed URL that breaks under reordering would be unusable. The values of one repeated key are the exception: their order is signed, because it decides what a handler reads.

Component Value
Algorithm HMAC-SHA256
Key Active APP_KEY raw bytes
Payload path?<sorted-query> (omit ? when no params)
Sort order By key, stable - every pair, repeats included in their original order
Encoding Hex-encoded 64-character digest
Comparison Constant-time via subtle::ConstantTimeEq
Reserved keys signature, expires

Repeated keys are signed, not collapsed. ?tag=a&tag=b carries both values into the payload, in that order, so none can be added, removed, substituted, or swapped without breaking the signature. The order matters because query_param returns a repeated key's last value: if ?mode=a&mode=b and ?mode=b&mode=a shared a signature, a client could swap them and change what the handler acts on. The signer keeps the order you pass. A URL signed by an earlier version, which sorted repeated values by value, still verifies.

This is worth stating because the alternative bites hard. An earlier version canonicalised into a map, which kept only the last value for a repeated key. Request::query_param returned the first. So a legitimately signed ?user=victim could be replayed as ?user=attacker&user=victim with the original signature: verification saw victim and passed, and the handler acted on attacker. Signed and executed were different URLs. All three query accessors - query_param, query_params, and Context::query_param - now resolve a repeated key to its last value, and the canonical form loses nothing.

A repeated signature or expires is refused outright. Those are control parameters; two of either leaves no non-arbitrary answer to "which one governs?", and the verifier should not be the component guessing.

The HMAC payload excludes any pre-existing signature query parameter (so signing-over-signing is a no-op) and re-emits a fresh expires value from the call arguments. A client that strips or rewrites the expires breaks the signature; a client that strips the signature fails as Invalid. Both fail closed.

The fragment (#section) is stripped from the canonical form because browsers never transmit fragments back to the server. Signing over a fragment would invalidate every link the moment a client appended an anchor - ?signature=...#docs would not verify on the server side.

Reserved query parameters

signature and expires are reserved query-parameter names. A route that legitimately expects a query parameter called signature or expires would collide with the signed-URL machinery, and the verifier would mis-attribute the value. Either rename the parameter or wrap the route's incoming parameters under a different namespace.

// Bad - `signature` collides with the reserved name.
get!("/api/check", check)  // takes ?signature=hash

// Good - namespace it.
get!("/api/check", check)  // takes ?body_signature=hash

The constants are exposed for symmetry with the Laravel wire format:

use suprnova::routing::{SIGNATURE_KEY, EXPIRES_KEY};
// SIGNATURE_KEY == "signature"
// EXPIRES_KEY   == "expires"

Key rotation

Signed URLs use the same APP_KEY that powers Crypt::encrypt and session-cookie integrity. Rotating APP_KEY invalidates every previously-minted signature in flight - an in-flight password-reset email becomes a 403 the next time the user clicks it.

For most applications that is the correct behaviour. If you need graceful rotation with overlap (so old links keep working through a deployment window), use APP_KEY_PREVIOUS to carry the prior key forward; the keyring tries every installed key on verification. See the Hashing chapter for the full keyring story.

Errors and edge cases

A handful of failure modes are worth knowing about:

  • route(name, ...) returns None when the name is not registered. This is the lenient surface - silent failure is intentional so calling code can fall back to a default. Use try_route for a loud failure.
  • try_route returns Err(NameNotFound) for an unknown name and Err(MissingParams { name, missing }) when a required {placeholder} has no matching value.
  • url::signed_route and friends return FrameworkError when the encryption key isn't installed (e.g. you forgot APP_KEY in .env). This fails at boot in production because Crypt::init runs during Server::from_config; the error path here exists to surface misconfiguration loudly instead of producing unverifiable links.
  • has_valid_signature returns Ok(false), not Err, for an invalid or expired signature. The FrameworkError variant is reserved for "the server can't even check" failures (missing key).
  • A signed URL with a tampered expires verifies as Invalid, not Expired. The HMAC payload includes the expires value, so changing it breaks the signature first.
use suprnova::{routing::SignatureVerdict, url};

// All of these are Invalid, not Expired:
url::signature_verdict(&req)?;  // signature query param missing
url::signature_verdict(&req)?;  // signature is non-hex junk
url::signature_verdict(&req)?;  // path was tampered (/orders/1 → /orders/2)
url::signature_verdict(&req)?;  // any query param value was tampered
url::signature_verdict(&req)?;  // expires value was tampered

// This is Expired:
url::signature_verdict(&req)?;  // valid HMAC, but now > expires

Why Suprnova diverges

Laravel's URL facade carries asset(), secureAsset(), assetFrom(), and action(). Suprnova ships none of them - for deliberate reasons.

Assets. Suprnova's frontend story is Vite plus the filesystem disks (Filesystem), not a stand-alone asset helper. Vite's @vite('resources/app.ts') directive (or the Inertia adapter's equivalent) emits the correct hashed URLs in production and the dev-server URL in development. Building a parallel URL::asset() channel would split the asset story across two systems that have to agree about hashing, versioning, and which manifest is authoritative. The Vite side already won that responsibility.

Action routing. Laravel's action('UserController@show', ['id' => 1]) relies on PHP class-string routing - controllers are classes with methods, and the framework can reverse-look-up an action string. Rust handlers are free functions. The closest analogue is named routes, and route("users.show", &[("id", "1")]) is already the right interface. Re-introducing action-string routing on top of Rust handler types would add nothing real over named routes.

URL::forceScheme() / URL::forceRootUrl(). Laravel exposes these for tests and for sites behind reverse proxies that don't pass X-Forwarded-Proto. Suprnova handles both cases by configuration: APP_URL carries the canonical host, scheme and path. Behind a proxy, the request accessors read X-Forwarded-Proto, -Host, -Port and -Prefix from the addresses listed in APP_TRUSTED_PROXIES, and the URL helpers build on them; the request's own path is left as it arrived, so routes match it. There's nothing for forceScheme to override - APP_URL already says what the scheme is. For a proxy that serves the application under a path, see Serving under a path prefix.

What does land here is the user-facing shape consumers reach for, with the same Laravel-shaped names where they translate cleanly. The trim is intentional, not an oversight.

Next

  • Routing - declaring routes, naming them, route groups, resource routing, and the full per-method matching surface
  • Responses - Redirect::route, Redirect::signed_route, Redirect::back, and the rest of the redirect helper family that consumes URL generation
  • Hashing - APP_KEY lifecycle, key rotation, and the shared keyring that backs URL signing alongside encryption
  • Auth flows - the production users of signed URLs: password reset, email verification, and remember-me cookies
  • Requests - Request::path, Request::query, Request::route_is, and the reverse side of every helper in this chapter