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 |
Note: The current syntax uses
{$param}for URL parameters. This is being deprecated in favour of Symfony-style{param}syntax. New routes should use{param}— the old{$param}syntax will trigger a deprecation notice when routes are parsed.
#[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 ] );
}
#[Route( url: 'user/{$id}', httpMethod: 'GET' )]
public function user_profile( int $id ): Response
{
// ...
}
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 InvalidRouteMethodParameterTypeException if it encounters one.
#[Route( url: 'guild/{guildId}/member/{memberId}', httpMethod: 'GET' )]
public function guild_member( int $guildId, int $memberId ): Response
{
// ...
}
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
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
This is used internally by middleware to check whether the current request is an API route:
if ( str_starts_with( Router::$currentRoute->url, '/api/' ) )
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. Writing url: '/example/page' results in a double slash //example/page in the route list.
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.
Parameter name mismatch — the method parameter name must match the URL parameter name exactly. url: 'user/{id}' with a method parameter named $userId will throw RouteParameterException at dispatch time.
Union types on route parameters — int|string $id is not supported. Route method parameters must be a single scalar type. 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.