Most frameworks ask you to maintain a route table: a file that maps URL patterns to handlers, which you edit every time you add a page. It is a small tax, paid forever, and it drifts — the route file says one thing, the controller says another, and the truth is whichever one ran last.
Tiknix takes the other approach. There is one route registration in the entire application, and it looks like this:
// routes/default.php
Flight::defaultRoute();
That single line handles every URL in the app.
The rule
The URL shape is /controller/method/operation/id, and each segment is optional:
| URL | Resolves to |
|---|---|
/ | Index->index() |
/dashboard | Dashboard->index() |
/auth/login | Auth->login() |
/member/profile | Member->profile() |
/blog/post/edit/123 | Blog->post() with operation edit, id 123 |
Missing segments default to index. The controller class is the capitalized first
segment, resolved inside the app namespace. The trailing segments arrive as a parsed
operation object rather than as positional arguments:
public function post($params) {
$action = $params['operation']->name; // 'edit'
$id = $params['operation']->type; // '123'
}
The segments arrive by name, not by numeric position — there is no
$params[0]/$params[1] array of path parts. You get an
operation object (->name is the segment after the method,
->type is the one after that) and a route string holding anything
beyond those. The base controller wraps the two most common reads as helpers:
$id = $this->opId(); // /product/edit/5 -> '5'
$type = $this->opType(); // the segment after opId
// $params['route'] holds any remaining path beyond opId, as a string.
Note that $this->getParam('x') is a different thing: it reads POST body, query
string, and $_REQUEST — the request's data, not the URL's path segments.
Path segments come from operation/route; form and query values come from
getParam().
Creating a route means creating a method. Deleting a route means deleting a method. There is no second place for the two to disagree.
What the dispatcher actually enforces
The convention is simple, but the dispatch path is not naive. Before Tiknix calls anything, it runs four checks in order — worth knowing precisely, because the security of the whole app rests on them.
-
Permission first, before instantiation.
Flight::permissionFor()consults theauthcontroltable (through the permission cache) using the current member's level. A denied request never constructs the controller. See Permissions as Data for how those rows work. - The class must exist. Otherwise: 404, logged.
-
The class file must live under
controls/. Only real controllers are routable — the dispatcher checks the resolved file's path and refuses anything outside the controllers directory. It is a directory check rather than an allowlist, so new controllers work automatically with no list to maintain, and internal libraries stay unreachable from a URL. - The method must exist and be public. Private and protected methods are not reachable from a URL, which is how you write controller helpers safely.
If the method does not exist but the controller defines _fallback(), the request is
handed there instead — that is the hook for pretty URLs like /products/blue-widget,
where the second segment is data rather than a method name.
Anything that throws inside a controller is caught, logged, optionally reported, and turned into a 404 rather than a stack trace on a user's screen.
Why this is unusually good for agent-written code
Convention routing removes an entire category of coordination. When an AI assistant adds a feature, it does not have to find the route file, infer its format, insert an entry in the right place, and keep it consistent with a controller in another directory. It writes one method in one file and the URL exists.
It also makes the codebase self-describing. You do not need to read a route table to
know what the app exposes; the list of public methods across controls/ is
the list of endpoints. Tiknix's own introspection tooling relies on exactly that — the
describe MCP tool reports a controller's routes and permission levels by reading the
class, because the class is the source of truth. (See
Give Your AI Assistant a Map.)
The same controllers run on the command line
Because a route is just a method, cron jobs and CLI scripts do not need a parallel command
framework. lib/CliHandler.php detects CLI execution and dispatches to the same
controllers:
php public/index.php --control=cleanup --method=daily --member=1 --cron
One implementation, two entry points. A method you can call from a URL you can also schedule, and it behaves identically because it is identical.
When you want a real route
Convention routing does not lock out explicit routes. FlightPHP
is underneath, so anything it
supports still works — add a file in routes/ for versioned APIs, webhooks with
fixed paths, or patterns the convention cannot express:
// routes/api.php
Flight::route('/api/v1/users', ['Api', 'users']);
Flight::route('/api/v1/posts/@id', ['Api', 'post']);
Use this deliberately and sparingly. Every explicit route is a place where the URL and the code can drift apart again — which is the tax the convention was designed to remove.
Name methods for the URL you want, not for the code that's inside them. publicprofile()
is a fine method name if /member/publicprofile is the URL you want users to see.
In this framework, method naming is URL design.
- Every public method is a potential endpoint. That is the deal. A public helper on a controller is reachable at a URL, so helpers belong in
private/protectedmethods or inlib/. Treat "should this be public?" as a security question, not a style question. - PHP method names are case-insensitive. A convention of "uppercase methods aren't routable" is not enforced by the language —
/example/processinternalwill reachProcessInternal(). Use visibility, not capitalization, to keep something off the web. - A route with no permission row defaults to public — and this is not just a build-mode thing. It is worth being exact, because it is easy to assume the open behavior only applies while
build_modeis on. It does not. With build mode on, an unknown route auto-creates a permission row and allows the request. With build mode off, an unknown route still resolves toPUBLIC(the permission check falls through tolevel <= 101, which every visitor satisfies). Either way, a missing row is an open door, not a locked one. Audit yourauthcontrolrows before you ship, and read the checklist. - URLs and refactoring are coupled. Renaming a controller or a method changes public URLs and orphans its permission rows. Plan renames like the breaking changes they are, and leave a redirect if the URL was ever shared.
- Deep hierarchies don't fit. The convention is two levels plus two data segments. Genuinely nested resources (
/teams/7/projects/3/tasks) want an explicit route or a_fallback().