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 converts a parameter when building a link (id → slug); the reverse (slug → id) only serves the whitelist check, the presenter gets the slug from the 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

The address yields the slug, not the id

TranslatableRoute::match() does not translate parameters back. RestrictedTranslatableRoute converts the slug to an id only for the whitelist check and passes the original value on, so getParameter('categoryId') returns 'automobily', not 12. The presenter looks the record up itself: a number through find(), otherwise by translations.href (see CategoryTrait::initCurrentCategory(), PageInitTrait::loadPageEntity()). Whoever pre-fills the slug from the address into a select whose options are keyed by id takes the page down with a 500, see Classifieds → Search box.

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.

A name as decoration next to the id: <name>-s<id>

When the id has its own capture in the mask (the set detail [<loc>/]<slug>/<name>-s<id>), the name only needs to be built. The parsing direction (addStringTranslation) returns null and the route matches on id, so an old or foreign-language name in the address still leads to the right record. The translator then needs neither nameToId() nor handling of an unknown name.

Key the translator cache by language

An "id → name" map without a language returns the name of whichever translation loaded last, so all languages link with the same (say, Slovak) slug. Key it as [languageId][id] and take a missing translation through AbstractTranslator::fromCacheWithLanguageFallback().

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.

A route with a non-existent presenter silently disappears

When building the snapshot, RouteTableBuilder skips a row whose class App\UI\Front\<Module>\Presenters\<Presenter>Presenter does not exist and records it only in log/info.log. The address then lands on another route or a 404. If you add the presenter after the table row, clear the route cache, otherwise the snapshot without that route stays in force.

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.

A link from a component drawn on other pages (a card, the footer, the language switcher) is therefore built through Front\BasePresenter::getDefaultUrlParams(): the constant DEFAULT_URL_PARAMS resets paging, filters, sorting, slug, id and more.

$presenter->link(':Front:System:Page:form', ['slug' => $pageId] + $presenter->getDefaultUrlParams());
{plink ':Front:System:Page:homepage' (expand) $presenter->getDefaultUrlParams(), loc: $key}

Your own parameters go on the left of +: an array union keeps the value from the left side and DEFAULT_URL_PARAMS contains 'slug' => null. The reverse order passes an empty slug and the link ends with "No route for …". Add a new front-end #[Persistent] parameter to DEFAULT_URL_PARAMS too, otherwise it starts leaking; tests/Unit/Core/DefaultUrlParamsCoverageTest.php checks it.

Address translators and their speed#

The translators (app/Core/Routers/Translators/<Module>/) turn an id into text and back. They must be fast, because they are called for every link on a page.

Thing How
SlugHelper::webalize() a replacement for Strings::webalize() with a shared transliterator and memoisation; 15 translators; bit-identical output
SlugMapCache id ⇄ slug maps of categories in Redis (Bazaar, Comcat, Blog, Discussion, e-shop), see Cache
the placeholder segment - means "no category/region"; answered without a query
a not-found region slug is remembered for the request, further links do not go to the database
warmup warmupTranslatorFromEntities() preheats only the nodes that get a link — never the whole tree

A translator with an unknown slug loads the whole table — once per request

The translators Blog\{Article,News,Event}NameTranslator, Bazaar|Comcat\AdvertNameTranslator, Discussion\MessageSubjectTranslator and System\PageSlugTranslator load the whole table once on an unknown slug (not repeatedly, warmedUp guards that). A candidate for SlugMapCache once measurement shows it.

SlugHelper is a copy of the vendor algorithm

Public URLs are built from its output, so it must stay bit-identical with Strings::webalize(). After every nette/utils update run tests/Unit/Utils/Helpers/SlugHelperTest.php; when the vendor algorithm changed, fix the copy in SlugHelper, not the test. Otherwise URLs from SlugHelper drift apart from those built by Strings::webalize() elsewhere, and links stop hitting their pages.

A unique page href within a language#

PageSlugTranslator keeps two maps per language: slugToId[language][href] = pageId for an incoming address and idToSlug[language][pageId] = href for building a link. The slug map is one per language for the whole site, regardless of module and branch of the page tree.

Two pages with the same href in one language Consequence
incoming address the page written into the map later wins, the other one is unreachable on the site
link to either of them both produce the same address, so a redirect after an action leads to the wrong page
reporting none, the error is not logged anywhere

Saving a page in the admin (PageFormTrait::validateHrefUniqueness()) therefore rejects the collision: it walks the form languages, looks up a non-empty href among the other pages and writes the error to the href_<langId> field together with the title and id of the colliding page. An empty href is not checked, a page without an address does not enter the map.

The check lives only in the form

Nothing guards a page created by a migration or a script outside the admin. Before inserting a row into cms_system_page_texts, make sure no other page has that href in that language; otherwise one of the pages silently disappears from the site.

Required mask parameters and a page in a menu#

A menu item points to a page and the page to a route; Link.latte builds the link. The menu, however, supplies only slug (from the page href in the given language), nothing else. RouteMaskHelper::requiredParams() therefore reads from the mask the parameters that Route::constructUrl() refuses to leave out:

In the mask Required?
<name>, <name [regex]> outside [...] yes
anything inside [...] (nested too) no
<name=default>, <name=default [regex]> no — a missing value is replaced by the default

The regex content ({3,}, [a-z]) does not affect whether a parameter is required; square brackets inside <...> do not count as an optional block. The live Nette route decides — RouteMaskHelperTest compares the parser with constructUrl().

Saving a page in the administration (PageFormTrait) uses it: when the page is in a menu and the selected route has a required parameter other than slug, the form refuses to save and puts the error on the route field. A required slug is a problem only in languages where the page has an empty href (all form languages are checked, not only active ones, because Link.latte reads href regardless of active).

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

A URL without a language prefix = the main localization#

A URL without a language prefix belongs to the website's main localization (localizations.main), not hard-wired Czech. Other languages have a prefix (/sk/…), and the prefix of the main language redirects 301 to the URL without it.

Part What it does
RouteTableBuilder reads the main localization key into RouteTableSnapshot::$defaultLocale; it survives the cache. An older cached snapshot without the key loads as cs
RouterDispatcher in all dynamic front masks it replaces <loc=cs …> from the database with <loc=main …> and sets the route's default locale (TranslatableRoute::setDefaultLocale()). It is one place; the route factories do not deal with it
the presenter's persistent $loc has no default value ('', LocalizationInitTrait). With 'cs', Nette would drop it from Czech links and the route would fill in the main language
static routes admin, Api and /script/… keep cs in the mask; they are not public URLs

Change the main localization through the application

The default language is carried by the route snapshot. Saving a localization through the Api invalidates it (Localization is among the RouteTableCache tags), raw SQL does not: until routerTtl expires, URLs without a prefix would still belong to the previous language. See The route cache.

?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.

Canonical host and robots.txt#

The address www.<host> is redirected to <host> and the literal prefix /www/… to /… (301, in www/.htaccess). /robots.txt is generated by the presenter Seo:Robots:default (a route in RouterDispatcher). In detail in the chapter Robots, robots.txt and sitemaps.

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

Cookie settings