| Home | Getting Started | Core Concepts | Helpers | Extensions | Repo |
Tiny has no route table. The URL path is mapped directly against app/controllers/ using a small, deterministic resolution algorithm.
Every URL is split into up to three segments:
/users/profile/edit
│ │ └── slug
│ └──────── section
└──────────────── controller
Anything beyond three segments is folded into slug. The framework only ever resolves to one controller file; it does not chain.
Given a URL /users/profile/edit, the router tries the following files in app/controllers/, in order, and uses the first one that exists:
users/profile/edit.phpusers/profile/<slashes-in-slug→hyphens>.php (only differs from #1 when the URL has more than three segments)users/profile-edit.phpusers/profile-<slug>.phpusers/profile.phpusers/index.phpusers.php404.php (custom; if missing, the framework returns a built-in error)This means you can organise controllers in whichever style fits the feature — flat, nested directories, or hyphenated leaves — without touching configuration.
app/controllers/
├── home.php -> /
├── about.php -> /about
├── users.php -> /users, /users/anything (if no override)
├── users/
│ ├── index.php -> /users
│ ├── profile.php -> /users/profile, /users/profile/<slug>
│ └── profile-edit.php -> /users/profile/edit
├── blog/
│ └── post.php -> /blog/post, /blog/post/<slug>
└── 404.php -> custom not-found
The URL / is dispatched to the controller named by TINY_HOMEPAGE (default: home), i.e. app/controllers/home.php.
<?php
class Blog extends TinyController
{
public function get($request, $response)
{
$controller = $request->path->controller; // "blog"
$section = $request->path->section; // segment 2 (or "")
$slug = $request->path->slug; // segment 3+ (or "")
$full = $request->path->full; // "/blog/2024/my-post"
$page = $request->query['page'] ?? 1; // ?page=2
$params = $request->params(); // merged GET + POST
$body = $request->body(true); // raw POST/PUT JSON or form
$isHtmx = $request->htmx; // bool: HX-Request header present
$response->render('blog/list', ['posts' => [/* … */]]);
}
}
What does not exist: there is no automatic mapping of URL segments to named parameters (no
:year, no:id). You readpath->sectionandpath->slugand decide what they mean.
TinyController dispatches to methods named after the HTTP verb in lowercase: get, post, put, patch, delete, options. Unimplemented methods return a placeholder; override only what you need.
class Article extends TinyController
{
public function get($request, $response) { /* … */ }
public function post($request, $response) { /* … */ }
public function patch($request, $response) { /* … */ }
public function delete($request, $response) { /* … */ }
}
The default options() responds with HTTP 204 and an Access-Control-Allow-Methods header listing the supported verbs.
Place a controller at app/controllers/404.php. The framework will dispatch to it automatically when no other controller matches. It receives the same request / response objects, and tiny::data()->error is pre-populated with a short reason string.
<?php
class NotFound extends TinyController
{
public function get($request, $response)
{
http_response_code(404);
$response->render('errors/404');
}
}
tiny::router()->permalink is the full canonical URL of the current request (scheme + host + URI). Use it for canonical tags, OG metadata, and HTMX redirects:
$canonical = tiny::router()->permalink;
Middleware runs before controller dispatch. Register middleware in app/middleware.php — see Middleware for the full contract.
posts/<slug> reads cleaner than three levels of nested directories.post, put, patch, delete) — see csrf.tiny::redirect() or $response->redirect(), both of which auto-degrade to HTMX HX-Redirect when appropriate.