Skip to content

Pramnos Modern Routing Guide

The Routing Engine in Pramnos v1.2 supports both attribute-based and fluent API routing with parameter binding, groups, and middleware support.

Class: Pramnos\Routing\Router

Getting Started

Define Routes

// routes/web.php or routes/api.php
$router = \Pramnos\Routing\Router::getInstance();

// Simple GET route
$router->get('/users', 'UserController@index');

// GET with parameter
$router->get('/users/{id}', 'UserController@show');

// POST route
$router->post('/users', 'UserController@store');

// PATCH/PUT route
$router->patch('/users/{id}', 'UserController@update');

// DELETE route
$router->delete('/users/{id}', 'UserController@destroy');

// Multiple methods
$router->match(['get', 'post'], '/search', 'SearchController@handle');

// All HTTP methods
$router->any('/webhook', 'WebhookController@handle');

Route Parameters

// Simple parameter
$router->get('/users/{id}', 'UserController@show');

// Constrained parameter (regex)
$router->get('/posts/{id}', 'PostController@show')->where('id', '[0-9]+');

// Multiple constraints
$router->get('/files/{year}/{month}/{slug}', 'FileController@show')
    ->where('year', '[0-9]{4}')
    ->where('month', '[0-9]{2}')
    ->where('slug', '[a-z0-9-]+');

// Global constraints (apply to all routes)
$router->pattern('id', '[0-9]+');
$router->pattern('slug', '[a-z0-9-]+');

Named Routes

$router->get('/users/{id}', 'UserController@show')->name('users.show');

// Generate URL from name
$url = route('users.show', ['id' => 42]);
// → /users/42

Route Groups

Prefix & Middleware

$router->group([
    'prefix'     => 'admin',
    'middleware' => ['auth', 'admin'],
], function ($router) {
    $router->get('/dashboard', 'Admin/DashboardController@show');
    $router->get('/users', 'Admin/UserController@index');
    $router->post('/users', 'Admin/UserController@store');
});

// Routes:
// GET /admin/dashboard
// GET /admin/users
// POST /admin/users

Route Groups with Namespaces

$router->group(['namespace' => 'Api'], function ($router) {
    $router->get('/users', 'UserController@index');
    // Resolves to Api\UserController@index
});

Nested Groups

$router->group(['prefix' => 'api'], function ($router) {
    $router->group(['prefix' => 'v1', 'middleware' => 'api'], function ($router) {
        $router->get('/users', 'UserController@index');
    });
});

// Route: GET /api/v1/users

Middleware

Apply Middleware

// Single route
$router->get('/profile', 'ProfileController@show')->middleware('auth');

// Multiple middleware
$router->post('/users', 'UserController@store')
    ->middleware(['auth', 'verified']);

// Entire group
$router->group(['middleware' => ['auth', 'csrf']], function ($router) {
    $router->post('/settings', 'SettingsController@update');
});

// Exclude middleware
$router->get('/login', 'AuthController@login')
    ->withoutMiddleware(['csrf']);  // CSRF not required for login form

Attribute-Based Routing

Route Attributes (PHP 8+)

<?php
namespace App\Controllers;

use Pramnos\Routing\Attributes\{Route, Middleware, Name};

class UserController extends \Pramnos\Application\Controller
{
    #[Route('GET', '/users')]
    #[Name('users.index')]
    public function index()
    {
        // GET /users
    }

    #[Route('GET', '/users/{id}')]
    #[Name('users.show')]
    public function show($id)
    {
        // GET /users/{id}
    }

    #[Route('POST', '/users')]
    #[Middleware('auth')]
    public function store()
    {
        // POST /users (requires auth)
    }

    #[Route('PATCH', '/users/{id}')]
    #[Middleware('auth')]
    public function update($id)
    {
        // PATCH /users/{id}
    }
}

Auto-Discovery of Routes

// Scan controllers for attributes
$router->discoverRoutes([
    'path'      => 'app/Controllers',
    'namespace' => 'App\\Controllers',
]);

HEAD is answered by GET

A route registered for GET answers HEAD as well. You do not declare it and you cannot forget it.

$router->addRoute('station/{slug}', 'GET', 'StationController@show');
Request Route
GET /station/athens matches, {slug} = athens
HEAD /station/athens matches, {slug} = athens
POST /station/athens no match

This is RFC 9110 §9.3.2 — "The HEAD method is identical to GET except that the server MUST NOT send content" — and it matters far more than the verb's reputation suggests: HEAD is what link checkers, uptime monitors, curl -I and several crawlers send first. Until 2026-08-18 the router kept a separate table per method and answered 404 to HEAD for every page, so an entirely reachable site reported as entirely broken. See the changelog post Every page answered 404 to HEAD.

Declaring HEAD explicitly still wins, and is worth doing when the answer can be cheaper than the GET:

// A GET that renders a report, and a HEAD that only decides whether it exists.
$router->addRoute('report/{id}', 'GET',  'ReportController@show');
$router->addRoute('report/{id}', 'HEAD', 'ReportController@exists');

The body is not the router's concern. PHP's SAPI drops the content of a HEAD response. If your action does expensive work to build output nobody will receive, check the method yourself:

if (\Pramnos\Http\Request::getInstance()->getRequestMethod() === 'HEAD') {
    return Response::make('')->withHeader('Content-Type', 'application/json');
}

Only HEAD falls back. POST, PUT, PATCH and DELETE never borrow the GET table.

Query strings and route matching

A query string never takes part in matching, and never lands in a placeholder.

$router->addRoute('station/{slug}', 'GET', 'StationController@show');
Request {slug} receives
/station/athens athens
/station/athens?fbclid=abc athens
/station/athens?return=/station/other athens

The parameters themselves are read the ordinary way — $request->get('fbclid', '', 'get') or $_GET — not from the route.

This is worth stating because it was wrong until 2026-08-18, and wrong in a way that only showed on routes with a placeholder. The pattern was tried against the URI with its query string still attached, and a placeholder compiles to [^/]+, which a query string satisfies — so /station/athens?fbclid=abc matched with a slug of athens?fbclid=abc, and the controller answered 404 for a page that exists. Every link shared on a network that appends a tracking parameter was affected. See the changelog post A placeholder that ate the query string.

One exception, by design: a route registered with a query string in its own URI matches on that exact form.

// Matches `/legacy?page=2` and nothing else.
$router->addRoute('legacy?page=2', 'GET', 'LegacyController@page');

Use it only for an address that must be preserved verbatim; ordinary paging belongs in a placeholder or in $_GET.

Leading slashes, and the shape of a request URI

Write the leading slash wherever you like — in the route, in Request::create(), in $_SERVER['REQUEST_URI']. Matching normalises all three.

$router->get('/stations/{id}', 'StationController@show');   // route: with
$router->get('stations/{id}',  'StationController@show');   // route: without — same thing

Request::create('/stations/7');   // → getRequestUri() === 'stations/7'
Request::create('stations/7');    // → getRequestUri() === 'stations/7'

getRequestUri() always answers without leading or trailing slashes, whichever way the Request was built. Code that concatenates it into a path should still write '/' . ltrim($uri, '/') rather than '/' . $uri — cheap, and it survives a caller who sets the static directly.

This is worth stating because it was wrong until 2026-08-24. Request::create() stored its argument verbatim while the constructor trimmed, so the two disagreed; Route::matches() then prefixed a slash unconditionally and tried //stations/7 against an anchored pattern. Every route with a placeholder missed, and every static route worked — which is why it survived so long: the routes anybody tests first are the ones that were fine. See the changelog post The slash that only broke the routes with placeholders.

The legacy path decomposition — Request::calcParams()

Alongside the router there is the older mechanism: ?r=module/action/x, decomposed by Request::calcParams() into a controller, an action and $_GET entries. Applications migrating off their own request class need to know exactly what it does, so here it is, measured — the table is generated by running the code, and pinned by tests/Characterization/Http/RequestPathDecompositionTest.php.

r= controller action _option also in $_GET
dashboard dashboard empty
jobposts/view jobposts view
jobposts/view/479 jobposts view 479
a/b/c/d a b c => d
a/b/c/d/e a b e c => d, e => null

A trailing slash is not a segment: jobposts/view/ and jobposts/view are the same request. Keys already in $_GET — from a rewrite, a front controller, a middleware — survive.

Two anomalies that used to be in that table

Both fixed on 2026-08-31, and both were routing changes — they decided what ended up in $_GET for URLs already in use — so they are recorded here rather than quietly corrected.

Leftover path segments became $_GET keys. module/action/x set _option = x and $_GET['module'] = 'action': only $parts[2] was removed before the key/value pairing loop walked the rest, so the controller and the action were paired into an entry of their own. On /jobposts/view/479, code reading $_GET['jobposts'] got 'view'. The other two branches already removed them; that one was the outlier.

A slash inside the query string changed the decomposition. $slashes — which chooses the branch — was recounted after the query string had been appended to the path, so an ordinary return-url parameter moved the same path:

before:  r=jobposts/view/479                            → _option=479, jobposts=view
before:  r=jobposts/view/479 ?return=/account/settings  → _option=479, 479=null
now:     both                                           → _option=479

One slash was enough, and _option survived either way — which is why it went unnoticed. It was the leftover keys that moved, and code reading one of them by name read something else.

The append had no other purpose: explode('?', …) threw the query string away on the next line, $mainString[1] is never read, and $request is not used past that point. The slash count now comes from the path, and only the path.

If you relied on either — a controller reading $_GET['<module-name>'], or on a segment appearing as its own key — that key is gone. Read _option and the named pairs instead. The framework's own suite had no dependency on either: measured, the change failed four characterization tests written to record it and nothing else in 13,294.

$originalRequestNoChange — the request as it arrived

Two statics, and the difference is the whole point of the second:

Request::$originalRequest the routing parameter, which calcParams() may rewrite
Request::$originalRequestNoChange the same value as it arrived, never rewritten

Assigned when r is read, before calcParams() runs. Use the second when you need the URL the visitor actually asked for — building a canonical link, deciding whether to redirect to one — and the first when you want whatever the current route resolved to.

It was declared, cleared by resetInstance(), and never assigned until 2026-08-31, so every read answered ''. Worth stating because of the failure that produces: an application building a canonical URL from it gets sURL alone, compares that against the real address, finds them different, and redirects the page to itself. An infinite loop, on a page that looks correct in the source, with no error and no log line — curl -L gives up at ten hops and reports nothing about why.

The action is cleared on every call, as of 2026-08-31. It used to be written only when the path had a second segment and cleared only by resetInstance(), so calcParams('module') inherited the previous call's action. One request per process hides that; a test suite is one process for thousands of requests.

Telling a wrong verb from a wrong address — 405

getMatchedRoute() answers for the request's own method: matched, or not. That leaves two very different situations looking identical — a GET on a POST-only endpoint falls through exactly as a path nobody declared does — so an application can only answer 404 for both. That is honest and unhelpful: it tells an integrator to check the address when the address was right.

allowedMethodsFor() asks the other question. It returns every method the path is declared for, sorted, or an empty array when the path matches nothing at all:

$route = $router->getMatchedRoute($request);

if ($route !== null) {
    // dispatch as usual
}

$allowed = $router->allowedMethodsFor($request);

if ($allowed === []) {
    // Nothing serves this path under any method.
    return $this->notFound();                       // 404
}

// The path exists; the verb does not.
header('Allow: ' . implode(', ', $allowed));        // e.g. "GET, HEAD, POST"
return $this->methodNotAllowed($allowed);           // 405

RFC 9110 §15.5.6 makes the Allow header mandatory on a 405, which is why the method exists on the router rather than in your application: matching a URI pattern against a path — placeholders, optional segments, the query-string forms — is the router's own rule, and re-deriving it from getRoutesWithPermissions() would be a second spelling of the matching logic.

Two details worth knowing:

  • HEAD is reported wherever GET is, because the router serves it there. An Allow header that omitted HEAD would deny a request the router is about to answer successfully.
  • The request's own method is included when it matches. The question is "what is this path declared for", not "what else could you have sent" — which is also what an OPTIONS response wants.

This is a question, not a decision. What you answer, and whether you send Allow at all, stays with your application.

Route Model Binding

Implicit Binding

$router->get('/users/{user}', 'UserController@show');

// Automatically inject User model
class UserController extends \Pramnos\Application\Controller
{
    public function show(\App\Models\User $user)
    {
        // $user is automatically loaded by ID from the URL
        return view('users.show', ['user' => $user]);
    }
}

Custom Key

$router->get('/posts/{post:slug}', 'PostController@show');
// Binds by slug instead of ID

Fallback & View Routes

Catch-All Route

// Must be last!
$router->get('/{path}', 'PageController@show')->where('path', '.*');

Redirect Route

$router->redirect('/home', '/dashboard');
$router->redirectPermanent('/old-path', '/new-path');  // 301

View Route

// Render view directly without controller
$router->view('/about', 'pages.about');

// With data
$router->view('/contact', 'pages.contact', [
    'email' => 'contact@example.com',
]);

Route Listing

View All Routes

php vendor/bin/pramnos route:list

# Output:
# GET    /users              UserController@index
# GET    /users/{id}         UserController@show
# POST   /users              UserController@store

Complete Example

// routes/web.php
$router = \Pramnos\Routing\Router::getInstance();

// Public routes
$router->get('/', 'HomeController@show')->name('home');
$router->get('/about', 'PageController@show', ['page' => 'about'])->name('about');
$router->get('/contact', 'ContactController@show')->name('contact');
$router->post('/contact', 'ContactController@store');

// Authentication routes
$router->group(['middleware' => 'guest'], function ($router) {
    $router->get('/login', 'Auth/LoginController@show')->name('login');
    $router->post('/login', 'Auth/LoginController@store');
    $router->get('/register', 'Auth/RegisterController@show')->name('register');
    $router->post('/register', 'Auth/RegisterController@store');
});

// Protected routes
$router->group(['middleware' => ['auth', 'verified']], function ($router) {
    $router->get('/dashboard', 'DashboardController@show')->name('dashboard');
    $router->post('/logout', 'Auth/LogoutController@store')->name('logout');

    // User profile
    $router->get('/profile', 'ProfileController@show')->name('profile.show');
    $router->patch('/profile', 'ProfileController@update')->name('profile.update');
});

// Admin routes
$router->group(['prefix' => 'admin', 'middleware' => ['auth', 'admin']], function ($router) {
    $router->get('/', 'Admin/DashboardController@show')->name('admin.dashboard');

    $router->resource('users', 'Admin/UserController');  // REST resource
    $router->resource('posts', 'Admin/PostController');
});

// API routes
$router->group(['prefix' => 'api/v1', 'middleware' => 'api'], function ($router) {
    $router->get('/users', 'Api/UserController@index');
    $router->get('/users/{id}', 'Api/UserController@show')->where('id', '[0-9]+');
    $router->post('/users', 'Api/UserController@store')->middleware('auth:api');
});

REST Resource Routes

Auto-Generate CRUD Routes

// Generates all 7 RESTful routes
$router->resource('posts', 'PostController');

// Generated routes:
// GET    /posts              PostController@index
// GET    /posts/create       PostController@create
// POST   /posts              PostController@store
// GET    /posts/{id}         PostController@show
// GET    /posts/{id}/edit    PostController@edit
// PATCH  /posts/{id}         PostController@update
// DELETE /posts/{id}         PostController@destroy

Customize Resource Routes

$router->resource('comments', 'CommentController')
    ->only('index', 'show', 'store', 'destroy')  // Exclude create/edit
    ->except(['create', 'edit'])                  // Same as above
    ->names('comments.list', 'comments.view');    // Custom names

Route Groups

Programmatic groups — Router::group()

$router->group([
    'prefix'      => '/api/v1',
    'middleware'  => [ApiAuthMiddleware::class, ThrottleMiddleware::class],
    'permissions' => ['api:access'],
    'name'        => 'api.v1.',
], function (\Pramnos\Routing\Router $r): void {
    $r->get('/users',       [UserController::class, 'index'])->name('users.index');
    $r->post('/users',      [UserController::class, 'store'])->name('users.store');
    $r->get('/users/{id}',  [UserController::class, 'show'])->name('users.show');
});

// Registered routes:
//   GET  /api/v1/users       named api.v1.users.index
//   POST /api/v1/users       named api.v1.users.store
//   GET  /api/v1/users/{id}  named api.v1.users.show

// URL generation still works:
echo $router->route('api.v1.users.show', ['id' => 42]); // /api/v1/users/42

Supported attributes:

Key Type Description
prefix string URI prefix prepended to every route URI
middleware array Middleware prepended before each route's own middleware
permissions array Permission scopes merged with each route's own permissions
name string Logical name prefix prepended to every named route

Nested groups

$router->group(['prefix' => '/api', 'name' => 'api.'], function (Router $r): void {
    $r->group(['prefix' => '/v2', 'name' => 'v2.'], function (Router $r): void {
        $r->get('/items', fn() => ...)->name('items.index');
        // → GET /api/v2/items  named api.v2.items.index
    });
    $r->get('/status', fn() => ...);
    // → GET /api/status  (only outer prefix)
});

Middleware ordering

global middleware → group middleware → per-route middleware → action

#[RouteGroup] attribute

Apply group attributes declaratively on a controller class. RouteDiscovery reads the attribute and wraps all #[Route] methods in a Router::group() call:

use Pramnos\Routing\Attributes\Route;
use Pramnos\Routing\Attributes\RouteGroup;

#[RouteGroup(
    prefix:      '/api/v1',
    middleware:  [ApiAuthMiddleware::class],
    permissions: ['api:access'],
    name:        'api.v1.',
)]
class UserController
{
    #[Route('/users',      methods: 'GET',  name: 'users.index')]
    #[Route('/users',      methods: 'POST', name: 'users.store')]
    public function index(): void { ... }

    #[Route('/users/{id}', methods: 'GET',  name: 'users.show')]
    public function show(int $id): void { ... }
}

#[RouteGroup] constructor parameters:

Parameter Type Default Description
prefix string '' URI prefix
middleware array [] Middleware FQCN strings
permissions array [] Permission scopes
name string '' Name prefix

OpenAPI documentation from #[Route]

Because #[Route] attributes are the single source of truth the router dispatches from, the framework can generate an OpenAPI 3.0 document from them directly — no separate routes.php + @api comment blocks to keep in sync. This is the attribute-native alternative to the older apidoc/JSDoc flow.

php pramnos api:docs \
    --title='My API' --api-version=1.0.0 \
    --server=https://api.example.com \
    --overrides=src/openapi-overrides.json

Where it looks and where it writes. With no --controllers it takes the first of src/Api/Controllers or src/Controllers that exists, and with no --output it writes <document root>/api/openapi.json, where the document root is whichever of www, public, html or web holds an index.php. Both are printed:

Scanned src/Api/Controllers (namespace App\Api\Controllers)
Wrote 72 path(s), 96 operation(s) to /srv/app/public/api/openapi.json

That first line exists because its absence cost somebody an hour. The command used to default to src/Controllers and report only where it wrote, so an application keeping its API in src/Api/Controllers got Wrote 1 path(s), 1 operation(s) for 72 endpoints — every word of it true. A document describing one endpoint of seventy-two is not obviously broken; it is indistinguishable from an application that has one endpoint, so it gets published and believed.

For the same reason, a run that finds fewer operations than a sibling directory holds says so and names it:

src/Api/Controllers holds 96 operation(s) — more than the 1 found in src/Controllers.
Re-run with --controllers=src/Api/Controllers if that is the API.

Nothing is switched under you, and the check is skipped entirely when you passed --controllers yourself — naming the directory is a decision, not a guess to correct.

--namespace follows --controllers. It is derived from the application namespace in app/app.php plus the path after src/, so --controllers=src/Api/Controllers gives App\Api\Controllers. It used to append a fixed \Controllers regardless, which is why passing --controllers alone found nothing at all and still exited successfully.

What is derived automatically: paths and methods (with {param} segments becoming path parameters), operationId (from the route name), summary/description (from the handler's docblock), a bearerAuth security requirement for routes that declare permissions or an auth middleware, and tags from the controller name.

What cannot be inferred from routes alone — request/response schemas, examples — is supplied through the --overrides document, which is deep-merged over the generated one (scalars and objects are overridden per key; the generated paths are preserved). This mirrors the openapi-overrides.json convention used elsewhere.

Programmatic use (e.g. to serve the spec live) goes through the same generator:

use Pramnos\Routing\OpenApiGenerator;

$doc = (new OpenApiGenerator(
    ['title' => 'My API', 'version' => '1.0.0'],
    [['url' => 'https://api.example.com']],
    $overridesArray
))->fromDirectory(ROOT . '/src/Controllers', 'App\\Controllers');

What a classic-MVC action must accept

Controller::exec() calls every action the same way:

fn() => $this->$action($args)

$args is the request's arguments array. So an action must accept an array as its first parameter — mixed, array, iterable, or nothing at all:

// Right — and this is the convention the bundled controllers use
public function view(mixed $id = null): mixed
{
    $id = (int) \Pramnos\Http\Request::staticGetOption();
    // …
}

// Wrong — a guaranteed TypeError the moment anything routes to it
public function logs(string $name = ''): mixed

The URL segment is read with Request::staticGetOption(), not taken as an argument. The parameter is kept for the signature's sake and ignored.

A scalar declaration is not a bug that appears under some input: the action cannot be called at all. Five bundled actions had one — ServicesController's stop, start, restart and logs, which is every button on the services screen, and LogController::clearFile(). All are fixed, and a structural test now walks every bundled controller so the next one fails in the suite rather than on a click.

An administration area under a prefix

Admin screens usually want to live under one path, with their own layout and a floor on who may reach any of them:

/admin              → the admin front page
/admin/Users        → the Users controller
/admin/Applications/edit/5

Configure it once:

// app/app.php
'admin' => [
    'prefix'             => 'admin',     // omit, or leave empty, to switch the area off
    'theme'              => 'admin',     // theme used inside the area (optional)
    'min_usertype'       => 80,          // floor for reaching any of it (optional)
    'default_controller' => 'Dashboard', // what the bare /admin opens (optional)
],

Set default_controller unless you want the bare prefix to fall through to the site's own default — which is usually the public home page, and which for a signed-in visitor usually redirects to their account. An administrator clicking the area's logo would leave the area.

That is the whole setup: no per-controller prefix handling. The prefix is removed before routing splits the path into controller and action, so actions, _option and the key/value tail behave exactly as they do without it.

Where the area's code lives

The area has its own directory, the counterpart of src/Api/:

src/Admin/Controllers/Users.php     namespace <Ns>\Admin\Controllers;
src/Admin/Views/users/…
src/Controllers/Home.php            the site's own
src/Views/home/…

Inside the area the framework looks there first and falls through to the site's own — so an area holds the screens that belong to it, not a copy of the application. A shared Home, a shared partial and an application with no src/Admin at all keep working exactly as before.

Outside the area, src/Admin/ is not in scope. That is the part that matters:

/admin/Users   → <Ns>\Admin\Controllers\Users
/Users         → 404

A controller left in src/Controllers/ still answers on both paths, which is what keeps every existing project working — and is also a second front door to the same page, in the public theme, with no sidebar and outside the area's floor. pramnos init puts every admin screen under src/Admin/; pramnos project:publish-views publishes an admin view group to src/Admin/Views/ for the same reason. To move an existing project's screens, move the file and change its namespace — nothing else refers to it.

Health is deliberately not an admin controller: /health/check is the JSON endpoint an uptime monitor calls, and putting a usertype floor in front of a monitoring URL is how a project finds out its monitor has been reporting "down" for a week.

The directory is named Admin by convention; 'area' => 'Ops' in the admin config block makes it src/Ops/.

What changes inside the area

Outside Inside
Route Users/edit/5 Users/edit/5 — identical
Theme theme from app.php admin.theme, when set
Access each controller's own check that, plus min_usertype

The floor is not a replacement for the checks each controller makes; those still run, and several are stricter. It is what stops the area being browsable, so a screen that forgot its own check is not the only thing between an ordinary account and the dashboard.

The floor does not protect a controller that has no check of its own

A controller in src/Controllers/ answers on both /admin/Settings and /Settings, and only the first goes through the prefix. So a controller relying on the area's floor is protected on exactly the paths an attacker has no reason to use. Moving it under src/Admin/ closes the bare path — but the check is still the thing that protects the screen.

SettingsController was in that state until 2026-08-27: it declared its actions with addAuthAction(), which requires only being signed in, and had no usertype floor. /admin/settings correctly refused an ordinary account; /settings served it the whole form — including the SMTP host, user and password rendered into fields — and POST /Settings/saveSystem rewrote site_url, forcessl, admin_mail and the login lockout rules.

Every screen inside the area needs its own requiredUserType and a requireMinUserType() call in each action — it is on Application\Controller, so every controller has it. Treat the area's floor — and the src/Admin/ layout — as defence in depth, never as the check.

The two refusals differ on purpose. A guest is sent to sign in with a return= carrying the address they asked for, so signing in lands them where they were going. A signed-in user below the floor is sent to the site root instead — showing them a login form they are already past reads as a broken session, and they retype their password rather than understanding they lack the privilege.

A view inside the area cannot link with a bare sURL:

<a href="<?php echo sURL; ?>Users/edit/5">Edit</a>       <!-- leaves the area -->
<a href="<?php echo adminUrl('Users/edit/5'); ?>">Edit</a>  <!-- stays in it -->

The first one reaches the same controller with the site layout — no sidebar, no admin chrome — which is a confusing way to lose somebody mid-task. Every table row, "back" link and pagination control in the bundled admin views goes through adminUrl() for that reason, and there is a test that walks all three themes looking for the bare form.

With no area configured adminUrl('Users') is exactly sURL . 'Users', so one view serves an application that has an area and one that does not.

With no path it is a base, and ends in a slashadminUrl() is to the area what sURL is to the site, so adminUrl() . 'Users' and adminUrl('Users') are the same string. The breadcrumb partials rely on that.

In a template, use the constant. Two bases, each naming what it is:

<a href="<?php echo sURL; ?>login">Sign in</a>     <!-- the site -->
<a href="<?php echo URL; ?>Users">Users</a>        <!-- the administration area -->
<a href="<?php echo adminUrl('Users'); ?>">Users</a>   <!-- identical to the second -->

URL was a second name for the site URL, from before the framework had an area at all — sURL was defined from it and nothing else read it. It is the area's base now, so the two constants answer the two questions a link has.

adminUrl() is the same answer for code that runs where constants may not be defined — a controller under test, a CLI render — which is why the bundled views use it. Both are AdminArea::url() underneath, so they cannot disagree, and both equal sURL when no area is configured.

User-facing links stay bare on purpose. An administrator clicking "My account" wants the public account page, not an admin-framed copy of it, so account, login, register, Passkey and TwoFactorAuth are addressed with sURL.

Admin NavItems point into the area automatically, from anywhere — including the public site header, which shows the same section. With no area configured they are plain application URLs, as before. The underlying calls:

adminUrl('Users');                       // the helper the views use
\Pramnos\Http\AdminArea::url('Users');   // what it delegates to
\Pramnos\Http\AdminArea::isActive();     // is this request inside the area?

One thing to know

Detection happens in Application::__construct(), because the prefix has to be gone before anything constructs a Request — that is when the path is split. An application whose front controller builds a Request before its Application will route the prefix as a controller name. No scaffolded front controller does that, and the fix is to swap the two lines.

The prefix must match a whole segment: /administration is not inside an area mounted at admin, and REQUEST_URI is never rewritten, so every return=, log line and session record keeps the address the visitor actually asked for.

Reference

For related guides:

Topics covered:

  • Route definition with all HTTP methods
  • Parameter binding and constraints
  • Named routes and URL generation
  • Route groups with prefixes, middleware, permissions, and name prefixes
  • Attribute-based routing discovery (#[Route], #[RouteGroup])
  • Resource routes and REST conventions
  • Distinguishing a wrong verb from a wrong address (allowedMethodsFor(), 405 with Allow)