SEF (clean) URLs for ConfigBox / Kenedo pages
Audience: developers & AI agents · Scope: giving a custom page a clean SEF URL, plus the
/cb-api/…XHR endpoint · Last reviewed: 2026-07-11
How to give a custom ConfigBox page a clean, search-engine-friendly URL like
/quote-follow-up/AAE8273 instead of
index.php?option=com_configbox&controller=...&task=display&serial=AAE8273.
This is the mechanism used by the bccoldquote page (/cold-quote/...) and the
bcquotelandingpage quote follow-up page (/quote-follow-up/...). Use those two
as live reference implementations.
Two different routing mechanisms live here — don't confuse them:
- Menu-based SEF (most of this doc) — human-facing page links (
view=<name>) routed through a Joomla menu item by the component router.- The
/cb-api/…endpoint (jump) — a fixed frontname for XHR/API calls (controller=<c>&task=<t>&output_mode=view_only), routed by a system plugin independently of any menu item. It's what the JSserver.jslayer uses instead of the rawindex.php?...query string.
Prerequisites: SEF must be on (see environment.md); the menu item in step 3 is created with a migration (see migrations.md); for the overall layout and naming conventions see architecture.md.
TL;DR — three things must all be true
A link only becomes a clean SEF path when all three are in place. Miss any one
and you silently fall back to the ugly index.php?... query string.
- Generate the link with
view=<name>— nevercontroller=<name>&task=display. The router ignores any URL that has noview. - The controller implements the SEF router hooks —
getUrlSegments(),getSegmentMatching(),getViewNameFromUrlSegments(). - A published Joomla menu item exists pointing at
index.php?option=com_configbox&view=customview&viewname=<name>. Create it with a migration (see below).
How it works (the round trip)
Building a URL (link generation → pretty path)
KLink::getRoute('index.php?option=com_configbox&view=bcquotelandingpage&serial=AAE8273')
│
▼
ConfigboxBuildRoute(&$query) docroot/components/com_configbox/router.php
│ bails out immediately if $query has no 'view' ← reason rule #1 exists
│ resolves the controller class from the view name
▼
ConfigboxControllerBcquotelandingpage::getUrlSegments(&$query)
│ looks up the menu item for view=customview&viewname=bcquotelandingpage
│ sets $query['Itemid'], unsets view/serial, returns ['AAE8273']
▼
/quote-follow-up/AAE8273
Parsing a URL (incoming request → controller)
GET /quote-follow-up/AAE8273
│
▼
ConfigboxParseRoute(&$segments) docroot/components/com_configbox/router.php
│ active menu item is the customview one → viewname = bcquotelandingpage
│ asks the controller how to read the segments:
│ getViewNameFromUrlSegments() → 'bcquotelandingpage'
│ getSegmentMatching() → [0 => 'serial']
│ getSegmentParsing() → [] (identity)
▼
view=bcquotelandingpage & serial=AAE8273
│
▼
configbox.php → ConfigboxControllerBcquotelandingpage::display() (task defaults to 'display')
Key framework pieces:
| Piece | Location | Role |
|---|---|---|
ConfigboxBuildRoute / ConfigboxParseRoute | router.php | Joomla router entry points |
KenedoRouterHelper::getItemIdByLink() | external/kenedo/helpers/router.php | finds the menu item id for a customview link (published, type=component, client_id=0) |
KenedoController::getUrlSegments() etc. | external/kenedo/classes/KenedoController.php | base no-op hooks you override |
configbox.php | component root | view=X → controller ConfigboxControllerX, runs task (default display) |
Step by step
1. Generate links with view=
In your model/view, build links through KLink::getRoute() using view=<name>:
// GOOD — gets SEF'd
KLink::getRoute('index.php?option=com_configbox&view=bcquotelandingpage&serial=' . urlencode($serial), false, true);
// BAD — no 'view', so the router never SEFs it
KLink::getRoute('index.php?option=com_configbox&controller=bcquotelandingpage&task=display&serial=' . urlencode($serial), false, true);
KLink::getRoute($url, $encodeAmpersands, $absoluteSecure):
- 2nd arg —
truereturns&(use when the URL goes straight into HTML);falsereturns raw&(use for JS, redirects, or hrefs you assemble yourself / email). - 3rd arg —
trueforces an absolutehttps://URL (use for emails and anything off-page).
view=<name>resolves toConfigboxController<Name>and runs the defaultdisplaytask, so the page keeps working even before the menu item exists (just without the pretty path). AJAX tasks still usecontroller=<name>&task=<task>— only the human-facing page link needsview=.
2. Implement the SEF hooks on the controller
Override these in your KenedoController subclass. Minimal example for a page keyed
by a single serial (from controllers/bcquotelandingpage.php):
public function getViewNameFromUrlSegments($segments) {
return 'bcquotelandingpage';
}
public function getUrlSegments(&$queryParameters) {
$langTag = (!empty($queryParameters['lang'])) ? $queryParameters['lang'] : KenedoPlatform::p()->getLanguageTag();
$id = KenedoRouterHelper::getItemIdByLink('index.php?option=com_configbox&view=customview&viewname=' . $queryParameters['view'], $langTag);
if ($id) {
$queryParameters['Itemid'] = $id;
unset($queryParameters['view'], $queryParameters['viewname']);
}
if (!empty($queryParameters['serial'])) {
$serial = $queryParameters['serial'];
unset($queryParameters['serial']);
return array(0 => $serial); // becomes the path segment
}
return array();
}
public function getSegmentMatching($activeViewName, $segments) {
return array(0 => 'serial'); // path segment 0 → ?serial=
}
Notes:
- Alphanumeric ids go in the path raw. Serials match
^[a-zA-Z0-9]+$, so no encoding is needed and the URL stays readable. (The olderbccoldquotebase64-encodes its segment and decodes it back ingetSegmentParsing()— only do that if a segment can contain characters that aren't URL-safe.) - Override
getSegmentParsing()only when a segment needs transforming on the way in (e.g. base64-decode, slug→id). Returning[](the base default) uses the segment value as-is. - For multi-segment URLs, return more entries and map them in
getSegmentMatching()(seecontrollers/configuratorpage.phpfor a product/page two-segment example).
3. Create the Joomla menu item (via a migration)
Kenedo finds the URL anchor by looking for a published menu item whose link is
index.php?option=com_configbox&view=customview&viewname=<name>. Without it,
getItemIdByLink() returns nothing and you get no clean path.
Do not hand-edit the DB. Add an idempotent migration script. Migrations live in
data/customization/updates/<version>.php, run automatically on the next page load
(observers/System.php → ConfigboxUpdateHelper::applyUpdates()), and are tracked in
#__configbox_system_vars (key latest_customization_update_version). A script that
throws sets failed_update_detected = '1' and blocks all further updates until an
admin clears it — so keep migrations defensive.
Template (mirrors updates/0.5.46.php, which created the quote-follow-up item):
<?php
defined('CB_VALID_ENTRY') or die();
$db = KenedoPlatform::getDb();
$link = 'index.php?option=com_configbox&view=customview&viewname=YOURVIEW';
// Idempotent: skip if it already exists.
$db->setQuery("SELECT `id` FROM `#__menu` WHERE `client_id` = 0 AND `link` = '" . $db->getEscaped($link) . "'");
if (!$db->loadResult()) {
$db->setQuery("SELECT `extension_id` FROM `#__extensions` WHERE `type` = 'component' AND `element` = 'com_configbox'");
$componentId = (int)$db->loadResult();
// Menu root = the level-0 node (lft = 0). Append the new item as its last child.
$db->setQuery("SELECT `id`, `rgt` FROM `#__menu` WHERE `lft` = 0");
$root = $db->loadObject();
if (!empty($root) && !empty($componentId)) {
$rootId = (int)$root->id;
$rootRgt = (int)$root->rgt;
// Open a 2-wide gap at the right edge of the nested set for the new leaf.
$db->setQuery("UPDATE `#__menu` SET `rgt` = `rgt` + 2 WHERE `rgt` >= " . $rootRgt); $db->query();
$db->setQuery("UPDATE `#__menu` SET `lft` = `lft` + 2 WHERE `lft` >= " . $rootRgt); $db->query();
$item = new stdClass();
$item->id = null; // auto-increment; also disables ON DUPLICATE upsert
$item->menutype = 'system-seo'; // hidden SEO menu (same as cold-quote)
$item->title = 'Your Page';
$item->alias = 'your-page'; // becomes the URL path
$item->note = '';
$item->path = 'your-page'; // root-level item ⇒ path == alias
$item->link = $link;
$item->type = 'component';
$item->published = 1;
$item->parent_id = $rootId;
$item->level = 1;
$item->component_id = $componentId;
$item->checked_out = null;
$item->checked_out_time = null;
$item->browserNav = 0;
$item->access = 1; // Public
$item->img = ' ';
$item->template_style_id = 0;
$item->params = '{"menu_text":1,"menu_show":1,"menu-meta_description":"...","robots":""}';
$item->lft = $rootRgt;
$item->rgt = $rootRgt + 1;
$item->home = 0;
$item->language = '*'; // All
$item->client_id = 0; // site
$db->insertObject('#__menu', $item, 'id');
}
}
Why it's shaped this way:
- Idempotent — the existence check means re-runs (e.g. after a cleared failure) are no-ops.
- Nested-set safe — appending as the rightmost child of root only shifts the root's
rgt, so existinglft/rgtcontainment stays valid. - Portable —
component_idand the root are looked up at runtime, so the same script works on local/staging/production without hard-coded ids. system-seois a hidden menu used purely for routing — the page isn't meant to appear in a navigation. (Alternatively, create the item through the Joomla admin: Menus → New → ConfigBox → Custom View, viewname<name>. The admin rebuilds the nested set for you.)
KenedoPlatform::getDb()is aKenedoDatabasewrapper, not Joomla's driver. It hassetQuery/query/loadResult/loadObject/getEscaped/getQuoted/insertObject, but noquote()and notransactionStart/Commit/Rollback. Use#__as the table-prefix placeholder andgetEscaped()(no surrounding quotes added) for values.
The generic API endpoint (/cb-api/…) — XHR/API calls
Everything above routes page links through Joomla's menu system. XHR/API calls are different: they
address a controller + task (not a view), want raw output (no site chrome), and shouldn't
depend on a menu item existing. For these, ConfigBox exposes a generic endpoint:
[host]/cb-api/[controller]/[task] e.g. https://betacalco.ddev.site/cb-api/cart/reloadCartSummary
cb-api is a hard-coded frontname claimed before menu matching. The old query-string transport
still works unchanged — index.php?option=com_configbox&controller=cart&task=reloadCartSummary&output_mode=view_only
returns the same raw output — so nothing that used the old URL breaks. (With $sef_suffix off, as on
this site, there's no .html tail; on a multilingual site the URL gains a /{lang}/ prefix.)
⚠️ This is a deliberate exception to rule #1 (never edit ConfigBox/Kenedo core). The feature was cherry-picked from the upstream
configbox_joomlacomponent repo (commit7bd323b6+ system-plugin commits3afd0cd3/71094ec1) because it was wanted on the site before the next component release. A ConfigBox upgrade will overwrite the core files below — re-apply the patch after any upgrade. Patched files (all currently byte-identical to the upstream feature commit):
File Change plugins/system/configbox/configbox.phpthe frontname router ( parseFrontname/buildFrontname,ensureRoutingOrder) — extends the existingoutput_mode→formatpluginplugins/system/configbox/installer.phppostflight: enable + order after Language Filter …/com_configbox/external/kenedo/interfaces/KenedoPlatform.phpadds getEndpointUrl()to the platform interface…/external/kenedo/classes/KLink.phpKLink::getEndpointUrl()static passthrough…/external/kenedo/platforms/{joomla,magento,magento2,standalone,wordpress}/general.phpper-platform getEndpointUrl()(all five, or the interface fatals)…/com_configbox/helpers/view.phpemits urlEndpointBaseinto the AMD config…/com_configbox/assets/javascript/server.js(+server.min.jstwin)endpointUrl()helper; all XHR call sites route through it
How it wires together
- Platform primitive.
KLink::getEndpointUrl($controller, $task)→KenedoPlatform::p()->getEndpointUrl(...). The Joomla impl buildsindex.php?option=com_configbox&controller=&task=&output_mode=view_onlyand runs it throughgetRoute(). With SEF off it returns that plain query string — the graceful fallback, so callers use it unconditionally. - The frontname plugin (
PlgSystemConfigbox,const FRONTNAME = 'cb-api') attaches two router rules inonAfterInitialise()(site app + SEF on only):- Build (
PROCESS_BEFORE) rewrites only genuine endpoint URLs — those carryingcontrollerandtaskandoutput_mode=view_only— intocb-api/{controller}/{task}. Theoutput_modemarker is the discriminator: ordinary nav links (cart/addProductToCart, admin edit links) also carrycontroller+taskbut nooutput_mode, so they keep their normal routes. It must run atBEFORE, notDURING— core'sbuildSefRouteruns atDURINGand stripsoption, so aDURINGrule here would never see its own endpoint URLs (the 2026-07 SEF regression fix). - Parse (
PROCESS_BEFORE) claims/[lang/]cb-api/<controller>/<task>, setsoption/controller/task+output_mode=view_only+format=raw, and consumes the path so menu matching is skipped. Falls through untouched for every non-cb-apiURL.
- Build (
- The JS side.
helpers/view.phpemitsurlEndpointBaseas a template (/cb-api/__CONTROLLER__/__TASK__);server.js'sendpointUrl(controller, task)substitutes the placeholders (or falls back toconfig.urlXhrwhen the template is absent). Allserver.jsXHR call sites use it. The request body is unchanged, so the dispatcher readscontroller/taskfrom the path or the body either way.
The component dispatcher (configbox.php) already reads option/controller/view/task from the request
regardless of transport, so a parsed /cb-api/… request dispatches identically to the old form — which
is why the old way keeps working with no extra code.
Multilingual note
If the site becomes multilingual, plg_system_configbox must be ordered after
plg_system_languagefilter so the language prefix is applied before buildFrontname appends the
route. This self-heals: installer.php sets it on install and ensureRoutingOrder() re-checks (and
throws rather than emit broken URLs) on every request. The parse rule also skips a leading language
segment itself.
Verifying the endpoint
# Parse: endpoint resolves to raw component output (HTTP 200, no <!DOCTYPE), reloadCartSummary is read-only
curl -k -s -o /dev/null -w "%{http_code}\n" "https://betacalco.ddev.site/cb-api/cart/reloadCartSummary"
curl -k -s "https://betacalco.ddev.site/cb-api/cart/reloadCartSummary" | grep -qi '<html' && echo "FULL PAGE (bad)" || echo "raw (good)"
# Build: a CB page emits urlEndpointBase as the /cb-api/… template
curl -k -s "https://betacalco.ddev.site/" | grep -o 'urlEndpointBase[^,}]*'
# Old way still works: same raw output via the query string
curl -k -s "https://betacalco.ddev.site/index.php?option=com_configbox&controller=cart&task=reloadCartSummary&output_mode=view_only" | head -c 60
Verifying
After the migration has run (load any page once), confirm the round trip locally
(https://betacalco.ddev.site, self-signed cert ⇒ curl -k):
# Version bumped, no failure flag
mysql -uroot -p bc_master -e "SELECT \`key\`,\`value\` FROM e5xae_configbox_system_vars \
WHERE \`key\` IN ('latest_customization_update_version','failed_update_detected');"
# Menu item exists and is published
mysql -uroot -p bc_master -e "SELECT id,alias,link,published,lft,rgt FROM e5xae_menu \
WHERE link LIKE '%viewname=YOURVIEW%';"
# Nested set still consistent (expect 0)
mysql -uroot -p bc_master -e "SELECT COUNT(*) AS broken FROM e5xae_menu WHERE lft >= rgt;"
# Parse side: the pretty path loads the page (HTTP 200, no redirect to 404)
curl -k -s -o /dev/null -w "%{http_code}\n" "https://betacalco.ddev.site/your-page/SOMEID"
# Build side: rendered pages emit the clean path, not index.php?...
curl -k -s "https://betacalco.ddev.site/your-page/SOMEID" | grep -oE "your-page/[A-Za-z0-9]+" | head
Requires Joomla SEF to be on ($sef = true, $sef_rewrite = true in configuration.php).
Gotchas / checklist
- Link generated with
view=<name>(notcontroller=&task=). Noview⇒ no SEF. - Controller overrides
getUrlSegments(),getSegmentMatching(),getViewNameFromUrlSegments(). -
getSegmentMatching()keys line up with the segment indexesgetUrlSegments()returns. - Published menu item (
type=component,client_id=0,view=customview&viewname=<name>) exists. - Menu
aliasis unique within its parent — it's the literal path you'll see in the URL. - Migration is idempotent and inserts into the nested set as root's rightmost child.
- Don't base64 a segment that's already URL-safe; only transform in
getSegmentParsing()when needed. -
KLink::getRoute()flags:falseampersands for JS/redirect/email,trueto force absolute https.
Reference implementations in this repo
controllers/bccoldquote.php+ menu aliascold-quote(base64 segment +getSegmentParsing).controllers/bcquotelandingpage.php+ menu aliasquote-follow-up+updates/0.5.46.php(raw segment).docroot/components/com_configbox/controllers/configuratorpage.php— multi-segment SEF.