Framework
Core Concepts

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:

src/Product/Model/ProductModel.php
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():

src/Product/ProductConstructor.php
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:

RuleWhy
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 oneEither the generic Request or a model request. Anything else cannot be built from a request.
It returns a ResponseThe dispatcher prints the response; a method returning something else has nothing to print.
The path is uniqueTwo 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:

StepDoes
Router::has()Is there such a route at all?
Router::getAccessName()What level does it need?
The access checksIs 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.

Because the routing table is generated from the attributes, there is no route file to keep in sync — add a method, run build, and the route exists.

When a route does not run

Three things can stop a request before your method, and each has its own answer:

SituationThe client receives
No such routeA general path error.
The route needs a session and there is noneA logout response — or an auth error for an API call.
The caller's level is below the route'sThe 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");
}