Routes are defined using PHP 8 attributes directly on controller methods. The router parses all registered controller directories on first request, caches the result in Redis, and resolves subsequent requests against the cached route list with a flat array lookup.
use PHP_SF\System\Attributes\Route;
use PHP_SF\System\Classes\Abstracts\AbstractController;
final class ExampleController extends AbstractController
{
#[Route( url: 'example/page', httpMethod: 'GET' )]
public function example_page(): Response
{
return $this->render( welcome_page::class );
}
}
The #[Route] attribute accepts four parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
url |
string |
Yes | The URL path, without leading slash |
httpMethod |
string |
Yes | HTTP method — GET, POST, PUT, PATCH, DELETE |
name |
string |
No | Route name used in routeLink(). Defaults to the method name |
middleware |
string\|array |
No | Middleware to run before the controller method |
Routes are either page routes (HTML rendered inside the header/footer layout) or API routes (JSON, no layout). Declare API routes explicitly with the #[RouteApi] attribute — on a controller class or on a single route method:
use PHP_SF\System\Attributes\Route;
use PHP_SF\System\Attributes\RouteApi;
// Class level — every route of the controller is an API endpoint
#[RouteApi]
final class UserApiController extends AbstractController
{
#[Route( url: 'users', httpMethod: 'GET' )]
public function list(): JsonResponse { ... }
// Method-level opt-out inside an API controller (e.g. an HTML form)
#[RouteApi( false )]
#[Route( url: 'users/form', httpMethod: 'GET' )]
public function form(): Response { ... }
}
final class MixedController extends AbstractController
{
// Method level — only this route is an API endpoint
#[RouteApi]
#[Route( url: 'status', httpMethod: 'GET' )]
public function status(): JsonResponse { ... }
}
| Placement | Effect |
|---|---|
#[RouteApi] on a class |
All routes of the controller are API routes |
#[RouteApi] on a method |
Only that route is an API route; overrides the class attribute |
#[RouteApi( false )] |
Explicitly non-API — opts a method out of a class-level #[RouteApi] |
Being an API route changes three framework behaviours:
Response skips the header/footer layout — the body is sent as-isApiResponse::forbidden() (403 JSON) instead of a redirect back — see MiddlewareApiResponse::notFound() instead of the HTML 404 page — see API Response EnvelopeUse Router::isApiRoute() to check the resolved flag of the currently matched route (or pass a route object explicitly).
Return-type validation. The declared return type of an API route method is validated at route-registration time: only Response and JsonResponse are allowed (subclasses included — e.g. ApiResponse). Declaring a RedirectResponse — alone or inside a union — or any non-response type throws InvalidRouteReturnTypeException at boot, because a redirect's history.replaceState() JavaScript would corrupt the JSON response. This applies to #[RouteApi] routes and to routes classified as API by the deprecated prefix fallback.
Deprecation. Until PHP_SF 3.2, routes whose URL started with
/api/were implicitly treated as API routes. That prefix detection is deprecated since 3.2 — a deprecation notice fires whenever it classifies a route — and it is removed in 4.0. Note the interaction: as soon as#[RouteApi]appears anywhere in a controller (on the class or on any routed method), the prefix fallback is disabled for the whole controller — unmarked routes are non-API there even if their URL starts with/api/.
URL parameters use Symfony-style {param} syntax:
#[Route( url: 'user/{id}', httpMethod: 'GET' )]
public function user_profile( int $id ): Response
{
$user = User::find( $id );
return $this->render( user_profile_page::class, [ 'user' => $user ] );
}
URL parameters are automatically cast to the type declared in the method signature. Supported types are string, int, and float:
// /product/42/19.99
#[Route( url: 'product/{id}/{price}', httpMethod: 'GET' )]
public function product_page( int $id, float $price ): Response
{
// $id → 42 (int)
// $price → 19.99 (float)
}
Union types are not supported on route method parameters — the router will throw RouteParameterException if it encounters one.
#[Route( url: 'guild/{guildId}/member/{memberId}', httpMethod: 'GET' )]
public function guild_member( int $guildId, int $memberId ): Response
{
// ...
}
Type a method parameter as an AbstractEntity subclass and the router fetches the entity automatically before calling the controller method.
Positional matching — parameters are matched by position, not by name. The first method parameter maps to the first URL placeholder, the second to the second, and so on. This applies to both entity parameters and scalar parameters.
The URL placeholder name decides the field used in findOneBy():
{slug} on Post calls findOneBy(['slug' => $value]).Id or _id, such as {paymentId} or {payment_id} — falls back to id, because it names the entity's key rather than one of its properties.RouteParameterException, so a typo like {slgu} stays a loud error instead of silently becoming an id lookup.Because matching is positional, placeholder names do not have to be unique — see Repeated placeholder names below.
Nullable vs non-nullable:
?Post $post — if the entity is not found, null is passed to the method. The controller handles the missing case.Post $post — if the entity is not found, the router returns an automatic 404. For page routes this is an HTML error page; for API routes (#[RouteApi]) this is the full ApiResponse::notFound() envelope.Example — the URL placeholder is {id} but the method parameter is named $post:
// The URL placeholder name 'id' is the DB field → findOneBy(['id' => $value])
// The method parameter name '$post' is irrelevant — matching is positional
#[Route( url: 'blog/{id}', httpMethod: 'GET' )]
public function post_detail( Post $post ): Response
{
return $this->render( post_detail_page::class, [ 'post' => $post ] );
}
Nullable example — handle not-found in the controller:
#[Route( url: 'blog/{slug}', httpMethod: 'GET' )]
public function post_by_slug( ?Post $post ): Response
{
if ( $post === null )
return $this->render( not_found_page::class );
return $this->render( post_detail_page::class, [ 'post' => $post ] );
}
Mixed example — scalar and entity parameters together, matched positionally:
// URL: /user/{userId}/post/{slug}
// userId → first placeholder → first param → int cast
// slug → second placeholder → second param → findOneBy(['slug' => value])
#[Route( url: 'user/{userId}/post/{slug}', httpMethod: 'GET' )]
public function user_post( int $userId, Post $post ): Response
{
// ...
}
Two entities keyed by id can be bound from one URL, whichever way the placeholders are spelled:
// Both segments named {id} — the second no longer overwrites the first
#[Route( url: 'crud/users/{id}/payment/{id}', httpMethod: 'GET', name: 'crud_user_one_payment' )]
public function crud_user_one_payment( ?User $user, ?Payment $payment ): Response
{
// /crud/users/7/payment/42 → $user = User#7, $payment = Payment#42
}
// Identical behaviour with distinct names — {paymentId} falls back to Payment::$id
#[Route( url: 'crud/users/{id}/payment/{paymentId}', httpMethod: 'GET', name: 'crud_user_one_payment_alt' )]
public function crud_user_one_payment_alt( ?User $user, ?Payment $payment ): Response
{
// ...
}
Each entity is resolved through its own entity manager, so the two may live in different databases.
Two constraints apply:
RouteParameterException when the route is registered, not on the first request.#[Response] attribute — so prefer the {id}/{paymentId} spelling there.To generate a URL for a route with a repeated placeholder, pass a list — see Generating URLs with routeLink().
Both systems run side by side in this project (see Symfony fallback), and they bind route parameters to controller arguments in fundamentally different ways. Knowing which one you are writing a controller for matters.
| Aspect | PHP_SF | Symfony |
|---|---|---|
| Argument binding | By position — first placeholder to first argument | By name — the route attribute {id} fills the argument named $id |
| Placeholder names | Free-form, may repeat | Must be unique; RouteCompiler throws LogicException on a repeated name |
| Argument names | Irrelevant to binding | Load-bearing — renaming an argument breaks the binding |
| Entity lookup field | Placeholder name, falling back to id for {somethingId} |
Route attributes matching entity fields, or an explicit #[MapEntity( mapping: [...] )] |
| Extra attributes per argument | None | #[MapEntity] when the defaults do not fit |
| Value constraints | None — values are cast with settype() |
Inline requirements, e.g. {id<\d+>}, plus requirements: |
| Matching | Linear scan over the route list, then cached per URL | Single compiled regex matcher |
The same two-entity route in each system:
// PHP_SF — the placeholder names carry the mapping
#[Route( url: 'crud/users/{id}/payment/{paymentId}', httpMethod: 'GET' )]
public function one_payment( ?User $user, ?Payment $payment ): Response {}
// Symfony — the mapping is spelled out per argument
#[SymfonyRoute( '/crud/users/{userId}/payment/{paymentId}', methods: [ 'GET' ] )]
public function onePayment(
#[MapEntity( mapping: [ 'userId' => 'id' ] )] User $user,
#[MapEntity( mapping: [ 'paymentId' => 'id' ] )] Payment $payment,
): Response {}
Where PHP_SF is genuinely nicer. Two entities that are both keyed by id need no per-argument configuration — the convention ({paymentId} → Payment::$id) does what #[MapEntity( mapping: … )] has to state explicitly. Symfony's default resolver cannot infer it, because with two entities and an id attribute in the URL there is nothing to tell it which entity that attribute belongs to; omit the mapping and both arguments resolve from the same value. For the ordinary CRUD shape that PHP_SF controllers are full of, that is a real reduction in ceremony. Repeating {id} twice is something Symfony simply cannot express at all.
Where Symfony is genuinely better. Name-based binding is refactor-safe and positional binding is not:
// Swapping these two arguments changes nothing about the URL, and the router
// will not complain — it just binds the payment id to $user and vice versa
public function one_payment( ?Payment $payment, ?User $user ): Response {}
Both arguments are entity-typed and both placeholders are numeric, so a wrong order produces a silent 404 or, worse, the wrong records — no exception, no warning. Symfony would bind correctly regardless of argument order. Symfony is also more expressive where the convention runs out: #[MapEntity] supports lookups by expression, by composite key, by a different object manager, and excludes; PHP_SF supports "placeholder name, or id". And Symfony's inline requirements reject /user/abc before a controller is ever called. PHP_SF has no equivalent: on a scalar parameter 'abc' becomes 0 through settype() and the controller runs with a silently wrong value, and on an entity parameter the raw string is handed to findOneBy(), where it either misses or trips a type error in the driver.
Practical advice. Prefer distinct, self-describing placeholder names — {id}/{paymentId} over {id}/{id} — even though the router accepts both. The distinct spelling documents which entity each segment addresses, survives the Symfony route mirror used for OpenAPI, and reads the same as the equivalent Symfony route. Treat repeated {id} as an escape hatch for URL shapes you do not control, not as the default. And when a route has two same-typed parameters, check the argument order against the URL — that is the one mistake this design cannot catch for you.
When multiple routes could match the same URL, the router prefers the route with the fewest dynamic segments. Given these two routes:
#[Route( url: 'product/featured', httpMethod: 'GET' )]
public function featured_products(): Response { ... }
#[Route( url: 'product/{id}', httpMethod: 'GET' )]
public function product_page( int $id ): Response { ... }
A request to /product/featured will match featured_products() because it has no dynamic segments. A request to /product/42 will match product_page().
By default the route name is the controller method name. Set an explicit name with the name parameter:
#[Route( url: 'auth/login', httpMethod: 'GET', name: 'login_page' )]
public function login(): Response
{
return $this->render( login_page::class );
}
Route names are used by routeLink() to generate URLs:
routeLink( 'login_page' )
// → /auth/login
If two routes have the same name, the second one silently overwrites the first in the route list. Route names must be unique across the entire application.
// Basic
routeLink( 'login_page' )
// → /auth/login
// With path parameters
routeLink( 'user_profile', [ 'id' => 42 ] )
// → /user/42
// With query parameters
routeLink( 'user_profile', [ 'id' => 42 ], [ 'tab' => 'inventory' ] )
// → /user/42?tab=inventory
// With full URL
routeLink( 'user_profile', [ 'id' => 42 ], [], 'https://nations-original.com' )
// → https://nations-original.com/user/42
// Repeated placeholder — a list fills the occurrences left to right
routeLink( 'crud_user_one_payment', [ 'id' => [ 7, 42 ] ] )
// → /crud/users/7/payment/42
A scalar fills every occurrence of its placeholder, so [ 'id' => 7 ] on the route above yields /crud/users/7/payment/7. Pass a list whenever the occurrences differ; a list whose length does not match the number of occurrences throws RouteParameterExpectedException.
routeLink() caches its results in Redis/APCu. The cache key is derived from the route name and all parameters, so each unique combination is cached independently.
If the route name doesn't exist in the framework route list, routeLink() tries Symfony's router before giving up. If neither system knows the route, it returns #routeName rather than throwing — useful for catching missing routes during development.
Assign middleware via the middleware parameter. See Middleware for full details on writing and composing middleware — this section covers only the route attribute syntax.
#[Route( url: 'dashboard', httpMethod: 'GET', middleware: auth::class )]
public function dashboard(): Response
{
return $this->render( dashboard_page::class );
}
use PHP_SF\System\Classes\MiddlewareChecks\MiddlewareAll as all;
#[Route( url: 'api/admin/users', httpMethod: 'GET', middleware: [ all::class => [ auth::class, admin_example::class ] ] )]
public function admin_users(): JsonResponse
{
// ...
}
use PHP_SF\System\Classes\MiddlewareChecks\MiddlewareAny as any;
#[Route( url: 'dashboard', httpMethod: 'GET', middleware: [ any::class => [ auth::class, api_example::class ] ] )]
public function dashboard(): Response
{
// ...
}
use PHP_SF\System\Classes\MiddlewareChecks\MiddlewareAll as all;
use PHP_SF\System\Classes\MiddlewareChecks\MiddlewareAny as any;
use PHP_SF\System\Classes\MiddlewareChecks\MiddlewareCustom as custom;
#[Route(
url: 'example/page/{response_type}',
httpMethod: 'GET',
middleware: [
custom::class => [
all::class => [ auth::class ],
any::class => [ api_example::class, admin_example::class ]
]
]
)]
public function example_route( string $response_type ): Response|RedirectResponse|JsonResponse
{
// ...
}
All five standard HTTP methods are supported:
#[Route( url: 'resource', httpMethod: 'GET' )]
public function get_resource(): JsonResponse { ... }
#[Route( url: 'resource', httpMethod: 'POST' )]
public function create_resource(): JsonResponse { ... }
#[Route( url: 'resource/{id}', httpMethod: 'PUT' )]
public function replace_resource( int $id ): JsonResponse { ... }
#[Route( url: 'resource/{id}', httpMethod: 'PATCH' )]
public function update_resource( int $id ): JsonResponse { ... }
#[Route( url: 'resource/{id}', httpMethod: 'DELETE' )]
public function delete_resource( int $id ): JsonResponse { ... }
The same URL can have multiple routes as long as the HTTP method differs. The router indexes routes by httpMethod first, then url, so GET /resource and POST /resource are entirely independent entries.
When DEV_MODE = false, the parsed route list is cached in Redis under two keys:
cache:routes_list → all routes indexed by name
cache:routes_by_url_list → all routes indexed by httpMethod + url
These keys have no TTL and persist until app:cache:clear is run. In DEV_MODE = true, routes are re-parsed from controller files on every request — useful during development when routes change frequently.
Per-URL resolution is also cached separately:
parsed_url:{httpMethod}:{sha256_of_url} → the matched URL
parsed_url:{httpMethod}:route:{sha256_of_url} → the matched route object
parsed_url:{httpMethod}:route_params:{sha256_of_url} → extracted URL parameters
This means even dynamic URL resolution (matching /user/42 against user/{id}) is cached after the first hit — subsequent requests to the same URL skip the matching loop entirely.
Controllers are registered by directory during kernel bootstrap:
$kernel = ( new PHP_SF\Kernel )
->addControllers( __DIR__ . '/../App/Http/Controller' );
The router recursively scans the directory for PHP files, extracts namespaces, and reads #[Route] attributes using reflection. Subdirectories are supported — organise controllers however makes sense for your application:
App/Http/Controller/
├── AuthController.php
├── Api/
│ ├── ApiCacheController.php
│ └── ApiLanguageController.php
└── Defaults/
├── DefaultController.php
└── ErrorPageController.php
The currently matched route is available anywhere during request handling via:
PHP_SF\System\Router::$currentRoute
It's a plain object with these properties:
Router::$currentRoute->url // '/user/{id}'
Router::$currentRoute->httpMethod // 'GET'
Router::$currentRoute->name // 'user_profile'
Router::$currentRoute->class // 'App\Http\Controller\UserController'
Router::$currentRoute->method // 'user_profile'
Router::$currentRoute->middleware // middleware config array or null
Router::$currentRoute->api // true|false when declared via #[RouteApi], null otherwise
To check whether the current request is an API route, use the resolved flag rather than sniffing the URL:
if ( Router::isApiRoute() )
return new JsonResponse( [ 'error' => 'Unauthorized!' ], 401 );
A debug endpoint is available in DefaultController that dumps the full merged route list from both the framework and Symfony:
GET /api/routes_list
This calls dd() so it only works in DEV_MODE. Use it to verify routes are being picked up correctly and to check for name collisions.
Wrong #[Route] import — always use PHP_SF\System\Attributes\Route in framework controllers. Importing Symfony\Component\Routing\Annotation\Route by mistake means the route is invisible to the framework router.
Leading slash in URL — the url parameter should not have a leading slash. The router adds it automatically if missing; if you already include one it is detected and not re-prefixed, so url: '/example/page' and url: 'example/page' both resolve to /example/page.
Route name collision — if two methods share the same name across different controllers, the second one silently overwrites the first. Always set an explicit name if method names aren't unique across the codebase, or keep controller method names globally unique.
Entity parameter placeholder resolves to nothing — for entity route parameters, the URL placeholder must either name a declared property on the entity class or be key-shaped (ending in Id or _id, which falls back to id). url: 'post/{nonExistentField}' with Post $post throws RouteParameterException at route registration time. Check the placeholder spelling against the entity's properties.
Parameter count mismatch — matching is positional, so the number of method parameters must match the number of URL placeholders exactly. A mismatch throws RouteParameterException when the route is registered.
Wrong argument order with same-typed parameters — a count mismatch is caught, but a wrong order is not. one_payment( ?Payment $payment, ?User $user ) against crud/users/{id}/payment/{paymentId} binds the user id to $payment and vice versa, with no exception and no warning: the router matches by position and both arguments are entity-typed. The symptom is a 404 or silently wrong records. Unlike Symfony, which binds by argument name, PHP_SF cannot catch this for you — check the order against the URL whenever a route has two parameters of the same kind. See How parameter binding differs from Symfony.
Union types on route parameters — int|string $id is not supported. Route method parameters must be a single scalar type or a single AbstractEntity subclass. Use string and cast manually inside the method if you need flexibility.
Forgetting to clear route cache after adding routes — in production (DEV_MODE = false) new routes won't be visible until app:cache:clear is run. If a new route returns 404 after deployment, this is almost always the cause.