Skip to main content

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 JS server.js layer uses instead of the raw index.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.

  1. Generate the link with view=<name> — never controller=<name>&task=display. The router ignores any URL that has no view.
  2. The controller implements the SEF router hooksgetUrlSegments(), getSegmentMatching(), getViewNameFromUrlSegments().
  3. 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)

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:

PieceLocationRole
ConfigboxBuildRoute / ConfigboxParseRouterouter.phpJoomla router entry points
KenedoRouterHelper::getItemIdByLink()external/kenedo/helpers/router.phpfinds the menu item id for a customview link (published, type=component, client_id=0)
KenedoController::getUrlSegments() etc.external/kenedo/classes/KenedoController.phpbase no-op hooks you override
configbox.phpcomponent rootview=X → controller ConfigboxControllerX, runs task (default display)

Step by step

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 argtrue returns &amp; (use when the URL goes straight into HTML); false returns raw & (use for JS, redirects, or hrefs you assemble yourself / email).
  • 3rd argtrue forces an absolute https:// URL (use for emails and anything off-page).

view=<name> resolves to ConfigboxController<Name> and runs the default display task, so the page keeps working even before the menu item exists (just without the pretty path). AJAX tasks still use controller=<name>&task=<task> — only the human-facing page link needs view=.

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 older bccoldquote base64-encodes its segment and decodes it back in getSegmentParsing() — 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() (see controllers/configuratorpage.php for 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.phpConfigboxUpdateHelper::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 existing lft/rgt containment stays valid.
  • Portablecomponent_id and the root are looked up at runtime, so the same script works on local/staging/production without hard-coded ids.
  • system-seo is 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 a KenedoDatabase wrapper, not Joomla's driver. It has setQuery/query/loadResult/loadObject/getEscaped/getQuoted/insertObject, but no quote() and no transactionStart/Commit/Rollback. Use #__ as the table-prefix placeholder and getEscaped() (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 unchangedindex.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_joomla component repo (commit 7bd323b6 + system-plugin commits 3afd0cd3/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):

FileChange
plugins/system/configbox/configbox.phpthe frontname router (parseFrontname/buildFrontname, ensureRoutingOrder) — extends the existing output_modeformat plugin
plugins/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 urlEndpointBase into the AMD config
…/com_configbox/assets/javascript/server.js (+ server.min.js twin)endpointUrl() helper; all XHR call sites route through it

How it wires together

  1. Platform primitive. KLink::getEndpointUrl($controller, $task)KenedoPlatform::p()->getEndpointUrl(...). The Joomla impl builds index.php?option=com_configbox&controller=&task=&output_mode=view_only and runs it through getRoute(). With SEF off it returns that plain query string — the graceful fallback, so callers use it unconditionally.
  2. The frontname plugin (PlgSystemConfigbox, const FRONTNAME = 'cb-api') attaches two router rules in onAfterInitialise() (site app + SEF on only):
    • Build (PROCESS_BEFORE) rewrites only genuine endpoint URLs — those carrying controller and task and output_mode=view_only — into cb-api/{controller}/{task}. The output_mode marker is the discriminator: ordinary nav links (cart/addProductToCart, admin edit links) also carry controller+task but no output_mode, so they keep their normal routes. It must run at BEFORE, not DURING — core's buildSefRoute runs at DURING and strips option, so a DURING rule here would never see its own endpoint URLs (the 2026-07 SEF regression fix).
    • Parse (PROCESS_BEFORE) claims /[lang/]cb-api/<controller>/<task>, sets option/controller/task + output_mode=view_only + format=raw, and consumes the path so menu matching is skipped. Falls through untouched for every non-cb-api URL.
  3. The JS side. helpers/view.php emits urlEndpointBase as a template (/cb-api/__CONTROLLER__/__TASK__); server.js's endpointUrl(controller, task) substitutes the placeholders (or falls back to config.urlXhr when the template is absent). All server.js XHR call sites use it. The request body is unchanged, so the dispatcher reads controller/task from 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> (not controller=&task=). No view ⇒ no SEF.
  • Controller overrides getUrlSegments(), getSegmentMatching(), getViewNameFromUrlSegments().
  • getSegmentMatching() keys line up with the segment indexes getUrlSegments() returns.
  • Published menu item (type=component, client_id=0, view=customview&viewname=<name>) exists.
  • Menu alias is 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: false ampersands for JS/redirect/email, true to force absolute https.

Reference implementations in this repo

  • controllers/bccoldquote.php + menu alias cold-quote (base64 segment + getSegmentParsing).
  • controllers/bcquotelandingpage.php + menu alias quote-follow-up + updates/0.5.46.php (raw segment).
  • docroot/components/com_configbox/controllers/configuratorpage.php — multi-segment SEF.