Koldy PHP Framework — Routing
Koldy provides two routing systems. Both extend Koldy\Route\AbstractRoute.
HttpRoute (Recommended)
Koldy\Route\HttpRoute is a filesystem-based HTTP router that maps URI segments to namespaced PHP classes using PSR-4 autoloading. Each URI segment maps to a class, and the final class handles HTTP methods (get(), post(), patch(), delete(), etc.). This router supports dynamic and static matching, and supports context propagation through the request chain.
Configuration
// In application config
'routing_class' => \Koldy\Route\HttpRoute::class,
'routing_options' => [
'namespace' => 'App\\Http\\', // required — root namespace for handler classes
'debugFailure' => true, // optional — log why route resolution fails
'debugSuccess' => true // optional — log successful route resolution steps
]
How Routing Works
URI segments are processed left-to-right. Each segment is sanitized (slugified, then PascalCased) and resolved to a class in the namespace tree.
Example: GET /companies/splendido-solutions/invoices
companies— resolves toApp\Http\Companies(static match)splendido-solutions— resolves toApp\Http\Companies\__(dynamic match) - opportunity to load company and put it in context so it can be used in next controllerinvoices— resolves toApp\Http\Companies\__\Invoices→get()is called
Segment Sanitization
Segments are converted to class names: slugified, double dashes collapsed, dashes replaced with spaces, ucwords applied, spaces removed.
bank-accounts→BankAccountsmy-awesome-page→MyAwesomePage
Dynamic Matching (__)
A class named __ (double underscore) acts as a wildcard catch-all for any segment value. The raw segment is available via $this->segment in the __ controller.
Dynamic matches always take precedence over static matches. If both __ and a named class exist at the same namespace level, __ wins. This approach allows you to have a catch-all controller for a given level of URI hierarchy but also makes it clear so you don't mix static and dynamic controllers at the same level.
// App\Http\Companies\__.php
namespace App\Http\Companies;
use Koldy\Route\HttpRoute\HttpController;
class __ extends HttpController
{
public function __construct(array $data)
{
parent::__construct($data);
// $this->segment contains the raw URI segment (e.g. "splendido-solutions")
// Load the company and pass it via context
$this->context['company'] = Company::fetchOneOrFail(['slug' => $this->segment]);
}
}
Static Matching
If no __ class exists, the router looks for a class whose PascalCased name matches the segment.
// App\Http\Companies.php — handles /companies
namespace App\Http;
use Koldy\Route\HttpRoute\HttpController;
use Koldy\Response\Json;
class Companies extends HttpController
{
public function get(): Json
{
return Json::create(['companies' => Company::fetch()]);
}
public function post(): Json
{
// Handle POST /companies
return Json::create(['created' => true]);
}
}
Context Propagation
Each controller in the chain receives a $context array. Controllers enrich the context by adding data, and the next controller receives the updated version. This enables deep URI structures to accumulate state without global variables.
// App\Http\Companies\__\Invoices.php — handles /companies/{slug}/invoices
namespace App\Http\Companies\__;
use Koldy\Route\HttpRoute\HttpController;
use Koldy\Response\Json;
class Invoices extends HttpController
{
public function get(): Json
{
// $this->context['company'] was set by the __ controller above
$company = $this->context['company'];
return Json::create(['invoices' => $company->getInvoices()]);
}
}
HttpController Base Class
All HttpRoute controllers must extend Koldy\Route\HttpRoute\HttpController:
abstract class HttpController
{
public array $context; // accumulated context from parent controllers
public string|null $segment; // raw URI segment that matched this controller
public function __construct(array $data);
// Execute a callback only once per request cycle
protected function once(string $name, callable $callback): void;
}
The once() method is useful when abstract controllers in the inheritance chain might otherwise run shared logic multiple times (e.g. authentication checks).
For example, you don't want to do "authentication" on every route, so you make a class like "abstract AuthenticatedUsers extends HttpController" and in constructor, you should then call $this->once('named-code', function() { ... }), because if multiple controllers extends that abstract controller, then that code will be executed multiple times, but with once() method, it will be executed only once. Example:
abstract class AuthenticatedUsers extends HttpController
{
protected User $user;
public function __construct(array $data)
{
parent::__construct($data);
$this->once('authenticate-user', function() {
// ... authentication logic
// and then you probably want to remember it
$this->user = $user;
});
}
}
class Companies extends AuthenticatedUsers
{
public function get(): Json
{
// $this->user is authenticated user and it's available in all child classes
return Json::create(['companies' => $this->user->getCompanies()]);
}
}
HTTP Method Dispatch
When the last URI segment's controller is resolved, the router dispatches to the matching HTTP method handler using this precedence. This applies only to the final controller in the chain — intermediate controllers (those handling non-terminal segments) are never dispatched via __call.
- Exact method — if the controller defines a method matching the HTTP verb (e.g.
get(),post()), it is called directly. This also coversoptions()if you want to handle OPTIONS yourself. - HEAD → GET fallback — if the request is HEAD and the controller defines
get()but nothead(), the router invokesget()and strips the body before sending. See "HEAD requests" below. __callfallback — if no exact method exists (and the HEAD fallback doesn't apply) but the controller implements PHP's__callmagic method, the router invokes it. This allows a single controller to handle any HTTP method generically.- 405 Method Not Allowed — if none of the above match, a
MethodNotAllowedExceptionis thrown. The exception carries the list of verbs the controller actually implements viagetAllowedMethods(), and the defaultResponseExceptionHandleruses it to populate theAllow:response header.
class Orders extends HttpController
{
// Called for GET /orders (and HEAD /orders — body is stripped automatically)
public function get(): Json
{
return Json::create(['orders' => Order::fetch()]);
}
// Catches any HTTP method that doesn't have its own handler. Without this method,
// unsupported verbs return 405 with `Allow: GET, HEAD` automatically.
public function __call(string $method, array $args): mixed
{
return (new Plain())->setCode(405);
}
}
When debugSuccess logging is enabled, the router logs both the missed method and the __call invocation:
HTTP: miss App\Http\Orders->delete(), exec __call()
404 vs 405
The router distinguishes between two failure modes:
- 404
NotFoundException— no controller class resolves for the URI path (the resource doesn't exist). - 405
MethodNotAllowedException— a controller is resolved but doesn't implement the requested verb (the resource exists, this method doesn't).
The 405 exception carries the implemented verbs (uppercased) via getAllowedMethods(). The default ResponseExceptionHandler reads that list and sets Allow: on the response, e.g. Allow: GET, POST, HEAD. HEAD is automatically included whenever GET is implemented, reflecting the implicit HEAD fallback.
HEAD Requests
HEAD is handled automatically — you don't need to define head() on your controllers. When a HEAD request arrives:
- If the controller defines
head(), it is called as you'd expect. - Otherwise, if
get()is defined, the router invokesget()and discards the response body before sending; only headers reach the client. The body is captured in an output buffer with a discard callback (chunk size 1), so streaming responses don't accumulate in memory. - Otherwise, 405 is returned.
You only need an explicit head() if you want to skip the cost of generating the body (e.g. expensive DB queries or rendering). Otherwise, the automatic fallback gives you correct HTTP semantics for free.
class LargeReport extends HttpController
{
public function get(): View
{
// expensive — runs heavy queries, renders a large view
return View::create('report')->with(...);
}
// Optional optimization: respond to HEAD without doing the expensive work.
// The Content-Length will be unknown to the client, but in exchange you skip
// generating the body entirely.
public function head(): Plain
{
return Plain::create()->setHeader('Cache-Control', 'no-cache');
}
}
OPTIONS is treated like any other verb — define options() if you want to handle it. The router does not auto-respond to OPTIONS; CORS preflight is expected to be handled at the web server (e.g. nginx) or in an explicit options() method.
Encoded Slash in Segments
URI segments are extracted from the still-encoded path first, then each segment is rawurldecode'd individually. This means an encoded slash (%2F) inside a single segment decodes to a literal / within that segment instead of splitting it into two segments.
For example, /files/foo%2Fbar/meta yields three segments — files, foo/bar, meta — and the literal / is passed verbatim to the matching __ controller via $this->segment:
namespace App\Http\Files;
class __ extends HttpController
{
public function get(): Json
{
// For /files/foo%2Fbar/meta, $this->segment is the string "foo/bar"
return Json::create(['path' => $this->segment]);
}
}
Exception Handling
If an ExceptionHandler class exists in the configured root namespace (e.g. App\Http\ExceptionHandler) with an exec() method, it handles exceptions. Otherwise, the framework's ResponseExceptionHandler is used.
Trailing Slash Behavior
GET/HEAD requests with a trailing slash are 301-redirected to the canonical URL without the trailing slash. The redirect is returned as a Koldy\Response\Redirect response through the normal lifecycle, so registered afterAnyResponse callbacks, session writes, and log flushers all run — the router never calls exit().
DefaultRoute (Legacy - not recommended)
Koldy\Route\DefaultRoute maps URLs to controllers using the pattern:
/[module]/[controller]/[action]/[param1]/[param2]/...
or without modules:
/[controller]/[action]/[param1]/[param2]/...
Configuration
'routing_class' => \Koldy\Route\DefaultRoute::class,
'routing_options' => [
'always_restful' => false // optional — force RESTful method naming
]
Controller Resolution
/→IndexController::indexAction()/users→UsersController::indexAction()/users/login→UsersController::loginAction()/users/show-details/5→UsersController::showDetailsAction()withgetVar(0)returning"5"
Action Method Naming
The action method name depends on request type:
| Request Type | Method Pattern | Example |
|---|---|---|
| Regular GET | {action}Action() |
loginAction() |
| AJAX POST | {action}Ajax() |
loginAjax() |
| RESTful | {httpMethod}{Action}() |
getLogin(), postLogin() |
RESTful mode is enabled per-controller via public static $restful = true; or globally via 'always_restful' => true in config.
Module Support
If the first URL segment matches a registered module directory, routing shifts:
/admin/users/edit/5→ Moduleadmin,UsersController::editAction(),getVar(0)="5"
URL Parameters
Access positional URL parameters via getVar():
$route = Application::route();
$id = $route->getVar(0); // first parameter after action
$slug = $route->getVar(1); // second parameter
Link Generation
$route = Application::route();
$url = $route->href('users', 'edit', ['id' => 5]);
// → https://example.com/users/edit?id=5
$url = $route->siteHref('api', 'users', 'list');
// → https://api.example.com/users/list (using sites config)