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#
- 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.
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.
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.
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());
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 |