Routing#
How a URL becomes a presenter and how parameters turn back into a pretty address.
Core: app/Core/Routers/.
This chapter is a guide: the first half is how it works, the second half holds procedures for the three most common tasks — adding a route, adding a pretty slug, and finding out why a link goes somewhere else than it should.
The router's two halves#
| Part | Defined where | Example |
|---|---|---|
| Static sections | in code, through factories in the DI container | /admin/…, /api/…, /cron/…, /script/… |
| Dynamic front-end routes | in the database (cms_system_routes) |
/inzeraty/<category>, /clanky/<slug> |
Front-end route masks are NOT in the code
This is the first thing people trip over. When you are looking for why the
classifieds listing has exactly that address, you will not find it in app/ —
it is a row in the database. The code holds only the factory that builds a
route from the mask and adds translations to it.
The router does not read the database directly: a snapshot of the route table is
built (RouteTableBuilder) and cached. On a cache hit, building the router costs
not a single SQL query.
request
│
▼
RouterDispatcher ──► static sections (factories from DI)
│
└─────────────► dynamic routes from RouteTableSnapshot
├─ cache hit → 0 SQL
└─ miss/off → RouteTableBuilder → DB
What a route factory does#
A mask on its own cannot do pretty addresses — only parameters. The factory adds translations to it:
$route = new RestrictedTranslatableRoute($mask, [
'module' => $module, 'presenter' => $presenter, 'action' => $action,
]);
// page id ⇄ slug in the address
$route->addTranslation('slug',
fn(int $id, string $loc) => $this->pageSlugTranslator->idToSlug($id, $loc),
fn(string $slug, string $loc) => $this->pageSlugTranslator->slugToId($slug, $loc),
);
// `name` appears in the address but is derived from `id` — links never pass it
$route->addDerivedParameter('name', 'id',
fn(int $id) => $this->advertNameTranslator->idToName($id),
);
| Element | What it is for |
|---|---|
addTranslation |
a two-way conversion of a parameter: id → slug when building a link, slug → id when reading an address |
addDerivedParameter |
a parameter links do not pass — it is computed from another one (typically a name from an id) |
applyWhitelist |
restricts the route to the pages it belongs to |
A derived parameter means shorter link calls
The template links only id; the router fills the advert name into the address
itself. Were it passed, every template would have to fetch it from somewhere —
and renaming would put them out of step.
Procedure: adding a new front-end route#
- A row in
cms_system_routeswith the mask, module, presenter and action. Write the mask the way the address should look:inzeraty/<category>[/p-<page>]. - Pick a factory. Plain parameters do with the existing generic one; a slug or
a name in the address needs a factory with translation
(
app/Core/Routers/Factories/<Module>/). - SQL into
docs/sql/as an idempotent script — a route is configuration, not a schema migration, but it is part of a deployment. - Drop the route cache (or wait out the TTL) — otherwise the old snapshot runs on.
- Click through both directions: an address typed by hand (it gets read) and a link generated by a template (it gets built). One is not enough.
A default value in the mask is DROPPED from the address
o-<order=0> means you will never see /o-0 in an address — Nette removes a
segment holding the default value and redirects (301) an incoming /o-0 to
the form without it. It is useful (it lets you express "default ordering =
relevance"), but anyone who does not know it hunts for a bug in the presenter.
A literal equal to the default gets swallowed by the route
When a mask has <action=detail> and somebody wants the address …/detail, the
segment disappears. The fix is a different default value, not a different template.
Procedure: I want a pretty name in the address instead of an id#
- Write a translator into
app/Core/Routers/Translators/<Module>/— it extendsAbstractTranslatorand providesidToName(), or bothidToSlug()/slugToId(). - Register it in the route factory:
$translator->addTargetFactory(self::class)in the constructor, and wire it increate()throughaddTranslation()oraddDerivedParameter(). - Verify both directions. Building a link (id → text) fails silently — you get an address with a number instead of a name. Reading (text → id) fails loudly, 404.
A translator must cope with an unknown value
A deleted or renamed record means an old link from somewhere outside arrives with
a value the translator does not know. Returning null is right (it ends as a
404); throwing is not.
Warm the translator up before building many links
A component that builds a lot of links warms its translator up — otherwise it hits the database once per row of the listing.
Procedure: the link goes somewhere I did not expect#
From the cheapest check up:
- Look at the mask in the database, not in the code. Nine surprises out of ten are there.
- Check the default values in the mask — a segment holding the default is dropped, and an incoming address is redirected to the canonical form.
- Check whether another route wins. Order decides; a route with a more generic mask and a lower order swallows the address before yours gets a chance.
- Drop the route cache and try again — after a change to the table the old snapshot runs until the TTL expires.
- Only then look in the presenter.
An orphaned route with a generic mask swallows EVERY address
A route with no pages attached gets an empty whitelist. With a generic mask and a low order it catches everything on the site — and it shows up as "random pages keep failing", not as a routing bug.
Persistent parameters leak into links
A parameter marked persistent shows up even in links nobody wanted it in. It looks like a route bug but it is a property of the presenter.
Canonicalisation and language#
| Thing | Behaviour |
|---|---|
| An address in a non-canonical form | 301 to the canonical one (defaults dropped) |
| Language | LocaleResolver plus the locale → language map from the snapshot |
| A search term | lives in the path (/clanky/s-<query>), not in the query string |
?search=… is thrown away by the router
It redirects before the presenter is reached. Anyone adding a search has to put it in the mask — otherwise the parameter disappears and nothing logs it.
The route cache#
Its own routerEnabled switch in config/Shared/cache.neon, independent of the
master cache switch. TTL routerTtl (300 s by default) with ±10 % jitter.
The TTL is a ceiling on staleness, not a solution
A write into the route table from outside the application (hand-written SQL) does not move the snapshot. The cache therefore holds for at most the TTL — and after a manual change it is cleaner to drop it than to wait.
Where to reach#
| I want to | Where |
|---|---|
| change the shape of an address | a row in cms_system_routes |
| add an id ⇄ text translation | app/Core/Routers/Translators/<Module>/ |
| wire a translation into a route | app/Core/Routers/Factories/<Module>/ |
| static sections (admin/api/cron) | app/Core/Routers/Factories/*RouteFactory.php |
| the route cache's behaviour | config/Shared/cache.neon → routerEnabled, routerTtl |