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:

URLResolves to
/Index->index()
/dashboardDashboard->index()
/auth/loginAuth->login()
/member/profileMember->profile()
/blog/post/edit/123Blog->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.

  1. Permission first, before instantiation. Flight::permissionFor() consults the authcontrol table (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.
  2. The class must exist. Otherwise: 404, logged.
  3. 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.
  4. 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.

Practical tip

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.

Honest notes
  • 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/protected methods or in lib/. 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/processinternal will reach ProcessInternal(). 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_mode is 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 to PUBLIC (the permission check falls through to level <= 101, which every visitor satisfies). Either way, a missing row is an open door, not a locked one. Audit your authcontrol rows 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().