AI blog · DotApp PHP Framework 2.0
How routing works in DotApp PHP Framework
Shop routes are registered in initialize($dotApp), not in index.php.
Router::get, post, match, and any attach a callable string or a closure.
STATIC_ROUTE is an exact URL; DYNAMIC_ROUTE is pattern matching.
Named routes do not exist. JSON endpoints are ordinary Router::get / post handlers that return Response::json.
The first matching route wins.
Common mistakes
| Wrong | Right |
|---|---|
Register application URLs in index.php |
Register them in the module’s initialize(). |
Read $request->id or a magic property for {id} |
$request->matchData()['id']. |
Type-hint extra services on Shop:Home@index! |
Trailing ! skips DI. Create Renderer::new() inside the method. |
Put a wildcard /shop/{*} above /shop/item/{id:i} |
First match wins. Register the specific path first. |
| Invent a name helper to generate URLs | Named routes do not exist. Concatenate Config::module('Shop', 'prefix'). |
| Expect a resource dispatcher from the URL | Each JSON verb is its own Router::get / post + Response::json. |
Verbs and static vs dynamic
Facades: Router:: and Route:: (alias).
Verbs: get, post, put, delete, patch, options, head, trace, match, any.
Router::get($path, $callback, $static = false);
Router::post($path, $callback, $static = false);
Router::match(['GET', 'POST'], $path, $callback, $static = false);
Router::any($path, $callback, $static = false);
| Third argument | Meaning |
|---|---|
Router::STATIC_ROUTE (true) |
Exact URL match. Use it for /, /save, /api/items. |
Router::DYNAMIC_ROUTE (false, default) |
Pattern matching for {id:i}, optional segments, wildcards. |
The call returns a chain object: ->before(), ->after(), ->middleware() (alias of before), ->throttle(), ->limitExceeded().
If the route does not match the current request, the chain is inert — later before() calls are no-ops for that request.
Path parameters
| Pattern | Meaning |
|---|---|
{param} |
Required segment |
{param?} |
Optional segment |
{param:i} |
Integer |
{param:s} |
String |
{param:l} |
Letters |
{param*} |
Greedy named remainder |
{*} |
Anonymous greedy remainder |
/prefix/* |
Prefix wildcard |
Read matched values from the request. Missing optional params are simply absent from the array:
$id = $request->matchData()['id'] ?? null;
Callable strings
| String | Resolves to |
|---|---|
Shop:Home@index! |
Controllers\Home::index without DI |
Shop:Home@index |
Same method with DI reflection |
#Shop:AuthGate@check! |
Middleware\AuthGate::check |
*Shop:Item@get! |
Models\Item::get |
Closures are valid: Router::get('/x', function ($request) { ... }).
Grammar: Callable strings in DotApp PHP Framework.
before / after
Returning a Response instance from a before-hook short-circuits the pipeline. Returning null or void continues.
Router::get($p . '/admin', 'Shop:Admin@index!', Router::STATIC_ROUTE)
->before('#Shop:AuthGate@check!');
Router::before(['POST'], [$p . '/*'], '#Shop:AuthGate@crc!');
Global forms: Router::before($callback), Router::before($routePattern, $callback),
or Router::before($method, $routePattern, $callback).
After-hooks run when the handler finishes without a short-circuit.
Grouping (named routes do not exist)
There is no name table and no URL generator keyed by a route name. Group with one of these:
- Prefix concatenation from
Config::module('Shop', 'prefix'). Router::onPath('/shop/admin*', function () { ... }).Middleware::use('is_admin')->group(function () { ... }).
use Dotsystems\App\Parts\Middleware;
Middleware::register('is_admin', function ($request, $next) {
return $next($request);
});
Middleware::use('is_admin')->group(function () use ($p) {
Router::get($p . '/admin/users', 'Shop:Admin@users!', Router::STATIC_ROUTE);
});
Router::onPath($p . '/reports*', function () use ($p) {
Router::get($p . '/reports/sales', 'Shop:Reports@sales!', Router::STATIC_ROUTE);
});
JSON endpoints
Register the verb yourself and return Response::json($array, $code = 200).
That sets the body and the JSON content type. There is no resource helper that invents getItems / postItems from the path.
If the browser posts the same URL with $dotapp().load() or <fo-rm>, still call $request->crcCheck() before you write.
public static function apiItems($request)
{
$pageNum = (int) ($request->query()['page'] ?? 1);
if ($pageNum < 1) { $pageNum = 1; }
$page = \Dotsystems\App\Parts\DB::module('RAW')->q(function ($qb) {
$qb->select(['id', 'title'])->from('shop_items')->orderBy('id', 'DESC');
})->paginate(20, $pageNum);
return Response::json(['status' => 1, 'page' => $page]);
}
public static function apiCreate($request)
{
if (!$request->crcCheck()) {
return Response::json(['status' => 0, 'message' => 'Bad request'], 400);
}
// insert with DB::module('RAW')->q(...)->execute($ok, $err)
return Response::json(['status' => 1]);
}
First match wins
Once a dynamic route matches the current request, later registrations may receive an inert chain for that request.
Keep lists ordered: static exact paths, then constrained params, then greedy {*} / prefix wildcards last.
Prefer php dotapper.php --list-routes over Router::hasRoute() when you inspect what is registered.
Complete module.init.php
<?php
namespace Dotsystems\App\Modules\Shop;
use Dotsystems\App\Parts\Config;
use Dotsystems\App\Parts\Middleware;
use Dotsystems\App\Parts\Router;
class Module extends \Dotsystems\App\Parts\Module
{
public function initialize($dotApp)
{
Config::module('Shop', 'prefix') ?? Config::module('Shop', 'prefix', '/shop');
Config::module('Shop', 'itemsPerPage') ?? Config::module('Shop', 'itemsPerPage', 20);
Config::module('Shop', 'public') ?? Config::module('Shop', 'public', true);
$p = Config::module('Shop', 'prefix');
Router::get($p . '/', 'Shop:Home@index!', Router::STATIC_ROUTE);
Router::get($p . '/item/{id:i}', 'Shop:Home@item!');
Router::post($p . '/item/save', 'Shop:Home@save!', Router::STATIC_ROUTE)
->before('#Shop:AuthGate@check!');
Router::match(['GET', 'POST'], $p . '/search/{q?}', 'Shop:Search@run!');
Router::any($p . '/health', 'Shop:Health@ping!', Router::STATIC_ROUTE);
Router::get($p . '/api/items', 'Shop:Items@index!', Router::STATIC_ROUTE);
Router::get($p . '/api/items/{id:i}', 'Shop:Items@show!');
Router::post($p . '/api/items', 'Shop:Items@save!', Router::STATIC_ROUTE);
Router::post($p . '/api/items/{id:i}', 'Shop:Items@update!');
Router::post($p . '/api/items/{id:i}/delete', 'Shop:Items@delete!', Router::STATIC_ROUTE);
Router::onPath($p . '/admin*', function () use ($p) {
Router::get($p . '/admin', 'Shop:Admin@index!', Router::STATIC_ROUTE)
->before('#Shop:AuthGate@check!');
});
Middleware::register('shop_staff', function ($request, $next) {
return $next($request);
});
Middleware::use('shop_staff')->group(function () use ($p) {
Router::get($p . '/staff/orders', 'Shop:Orders@index!', Router::STATIC_ROUTE);
});
}
public function initializeRoutes()
{
return ['/shop', '/shop/*'];
}
public function initializeCondition($routeMatch)
{
return $routeMatch;
}
}
new Module($dotApp);
Official router chapter: Router documentation. Module boot order: How module initialization works in DotApp PHP Framework.
FAQ
Where do I name a route?
Named routes do not exist. Store the prefix in config and concatenate. If two modules share a path, order registrations so the first match is the one you want.
When is STATIC_ROUTE required?
Use it when the path has no parameters. It is the fast exact match. Dynamic patterns ({id:i}, {q?}, {*}) stay on the default DYNAMIC_ROUTE.
When do I drop the trailing !?
Only when the method type-hints services you want injected. Hot paths keep ! and construct Renderer::new() themselves.
Details: callable strings and dependency injection.
How do I version a JSON API?
Put the version in the path you register: $p . '/api/v1/items'. Each verb is an explicit Router::get or Router::post that returns Response::json.
What happens on no match?
A dotapp.router.resolve.404 listener can take over. Otherwise the router error handler runs. HTTP 405 is not implemented — unknown verbs fall through the same miss path.
How do I rate-limit login?
Chain ->throttle(['per_minute' => 5, 'per_hour' => 40])->limitExceeded(function ($request) { return new Response(429, 'Too many attempts'); }).
Without limitExceeded, a 429 JSON response is sent and the process exits.