Skip to content
V
For developers
Architecture, conventions, the core and security
Core / Routing

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#

  1. A row in cms_system_routes with the mask, module, presenter and action. Write the mask the way the address should look: inzeraty/<category>[/p-<page>].
  2. 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>/).
  3. SQL into docs/sql/ as an idempotent script — a route is configuration, not a schema migration, but it is part of a deployment.
  4. Drop the route cache (or wait out the TTL) — otherwise the old snapshot runs on.
  5. 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#

  1. Write a translator into app/Core/Routers/Translators/<Module>/ — it extends AbstractTranslator and provides idToName(), or both idToSlug()/slugToId().
  2. Register it in the route factory: $translator->addTargetFactory(self::class) in the constructor, and wire it in create() through addTranslation() or addDerivedParameter().
  3. 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.

From the cheapest check up:

  1. Look at the mask in the database, not in the code. Nine surprises out of ten are there.
  2. Check the default values in the mask — a segment holding the default is dropped, and an incoming address is redirected to the canonical form.
  3. 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.
  4. Drop the route cache and try again — after a change to the table the old snapshot runs until the TTL expires.
  5. 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.neonrouterEnabled, routerTtl