Routing
Routes are plain methods annotated with #[Route]. The build discovers them and
generates a typed System\Router that dispatches requests.
Declaring a route
Add the #[Route] attribute to a public static method. The first argument is the path, the
second is the minimum access level required to call it. The method receives a
Request and returns a Response.
use Framework\Discovery\Attr\Route;
use Framework\System\Access;
use Framework\IO\Request;
use Framework\IO\Response;
class UserConstructor {
#[Route("/users/get", Access::General)]
public static function get(Request $request): Response {
$id = $request->getInt("id");
// …
}
#[Route("/users/create", Access::Admin)]
public static function create(Request $request): Response {
// …
}
}
Access is a minimum level
Every access role has a numeric level, and the one on a route is the
minimum: a request passes when the caller's level is at or above it. So
Access::General lets any signed-in user through, while Access::Admin also admits
everyone above admin — the check is currentLevel >= requiredLevel, never an exact
match. A caller below the required level is rejected before your method runs, and Access::None
marks a path as non-existent.
Routes backed by a Model
Instead of pulling loose values out of the generic Request, a route can take a
typed request generated from a Model. Mark the fields that make
up the request with the #[Requested] attribute:
use Framework\Database\Model\Model;
use Framework\Database\Model\Field;
use Framework\Database\Model\Requested;
#[Model(canCreate: true, canEdit: true)]
class ProductModel {
#[Field(isID: true)]
#[Requested(isID: true)]
public int $id;
#[Field]
#[Requested]
public string $name;
#[Field]
#[Requested(isNumber: true)]
public float $price;
}The build turns those into a <Model>Request class — here
ProductRequest — with a typed property per requested field (plus the id and the
isCreate / isEdit flags). Type-hint it in the route in place of Request,
and the generated Router builds it from the incoming request with
ProductRequest::fromRequest($request). Your handler receives the correct type, with typed,
autocompleted fields and no manual getString() / getInt():
use Framework\Discovery\Attr\Route;
use Framework\System\Access;
use Framework\IO\Response;
class ProductConstructor {
#[Route("/products/edit", Access::Admin)]
public static function edit(ProductRequest $request): Response {
$id = $request->id; // int
$name = $request->name; // string
$price = $request->price; // float
// …persist and return a Response
return Response::data($id);
}
}What a route method may look like
The build only accepts a method it can call safely, so the shape is fixed — and the analysis rules flag a mistake before the build even runs:
| Rule | Why |
|---|---|
| Its name matches the route | /users/get is served by get(), so the route and the code stay findable from each other. |
| No params, or exactly one | Either the generic Request or a model request. Anything else cannot be built from a request. |
| It returns a Response | The dispatcher prints the response; a method returning something else has nothing to print. |
| The path is unique | Two methods claiming one path would make the route ambiguous. |
A method that breaks the parameter or return shape is skipped when the routes are collected, so a route that silently does not exist is almost always one of those two.
// Takes nothing — for a route with no input
#[Route("/products/getAll", Access::General)]
public static function getAll(): Response {}
// Takes the generic Request
#[Route("/products/get", Access::General)]
public static function get(Request $request): Response {}
// Takes a typed model request
#[Route("/products/edit", Access::Admin)]
public static function edit(ProductRequest $request): Response {}
Dispatching
The build writes every route into a generated Router — a single match from path to
method, with no reflection and no lookup table at runtime:
use Framework\System\Router;
Router::has("/users/get"); // true
Router::getAccessName("/users/get"); // the required Access level
Router::call("/users/get", $request); // dispatch → Response
You rarely call these yourself —
Framework::execute() does, once per
request. Its order matters, because it decides what your method can assume:
| Step | Does |
|---|---|
Router::has() | Is there such a route at all? |
Router::getAccessName() | What level does it need? |
| The access checks | Is the caller signed in, and high enough? |
Router::call() | Only now is your method called. |
So by the time your code runs, the caller is known and allowed. There is no guard to write at the top of a
route, and Auth already answers who it is.
When a route does not run
Three things can stop a request before your method, and each has its own answer:
| Situation | The client receives |
|---|---|
| No such route | A general path error. |
| The route needs a session and there is none | A logout response — or an auth error for an API call. |
| The caller's level is below the route's | The same general path error. |
An unknown route and a forbidden one deliberately answer the same way, so probing for paths reveals nothing about what exists. If your method throws, the framework answers HTTP 400 and the exception lands in the error log.
Returning a response
A route ends by returning a Response, and the factory you pick is what the client switches on:
use Framework\IO\Response;
// Data for a screen
return Response::data([ "list" => $products, "total" => $total ]);
// A result, named by a translation key
return Response::success("PRODUCT_CREATED", [ "productID" => $id ]);
return Response::warning("PRODUCT_PARTIAL");
return Response::error("PRODUCT_ERROR_EXISTS");
// Validation failures, keyed by field
return Response::error($result->errors);
// Nothing to send back
return Response::empty();
The messages are translation keys rather than sentences, so the
client renders them in the user's language. On the way out, execute() attaches fresh
tokens for a non-API call, and prints the whole thing as JSON.
A typical route is therefore validate, act, respond — with no plumbing in between:
#[Route("/products/edit", Access::Admin)]
public static function edit(ProductRequest $request): Response {
$result = Product::validateRequest($request);
if ($result->hasError()) {
return Response::error($result->errors);
}
if ($request->isCreate) {
$productID = Product::create($request);
return Response::success("PRODUCT_CREATED", [ "productID" => $productID ]);
}
Product::edit($request->id, $request);
return Response::success("PRODUCT_EDITED");
}