Skip to main content
Version: 4.0 preview

SEF (clean) URLs for CBX / Kenedo on Joomla

Version
4.0 preview
Updated
View markdown

Audience: developers & AI agents · Scope: how CBX/Kenedo turns query-string links into clean SEF paths on Joomla, how to give a custom page its own clean URL, and the generic /cb-api/… endpoint used for XHR/API calls · Component: 3.4.1

When you need this page: when you build a custom view — a customization that adds its own controller/view (a quote follow-up page, a customer portal, any page CBX doesn't ship) — and want it reachable under a clean URL instead of a raw index.php?option=com_configbox&… query string. All built-in pages (product lists, configurator, cart, checkout) get their SEF URLs automatically; if you're not building a custom view, you can skip this page entirely.

How a link like index.php?option=com_configbox&view=configuratorpage&prod_id=1&page_id=2 becomes a clean path like /en/demo-products/car/motor.html, and how to give your own custom view a path like /quote-follow-up/AAE8273 instead of index.php?option=com_configbox&controller=...&task=display&serial=AAE8273.

There are two distinct routing mechanisms in play, and they must not be confused:

  • Menu-based SEF (most of this doc) — human-facing page links (view=<name>) routed by the component router (router.php / ConfigboxRouter) through a Joomla menu item. Covers §"How it works" through §"Built-in SEF".
  • The /cb-api/… endpoint (§"The generic API endpoint" below) — a fixed frontname URL for XHR/API calls (controller=<c>&task=<t>&output_mode=view_only), routed by a system plugin independently of any menu item. This is what the JS server.js layer now uses instead of the raw index.php?... query string.

This is grounded in the actual router code in this repo: docroot/components/com_configbox/router.php, …/external/kenedo/helpers/router.php (KenedoRouterHelper), …/external/kenedo/classes/KenedoController.php (the SEF hooks), …/external/kenedo/classes/KLink.php (KLink::getRoute). In-repo reference implementations: controllers/configuratorpage.php (multi-segment, label-based) and controllers/customview.php (the generic custom-view anchor). The bccoldquote / bcquotelandingpage pages referenced at the end are customization-layer examples from a downstream project (not in this base repo) but use the exact same hooks.

Prerequisites: Joomla SEF must be on — in this repo configuration.php has $sef = '1', $sef_rewrite = '1', $sef_suffix = '1' (the .html suffix). Menu items for custom views are created with a migration — see ../../technical/com_configbox_migrations.md.


TL;DR — three things must all be true (for a custom view)

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 bails immediately on any query that has no view (router.php:64).
  2. The controller implements the SEF router hooksgetUrlSegments(), getViewNameFromUrlSegments(), getSegmentMatching() (and optionally getSegmentParsing()).
  3. A published Joomla menu item exists pointing at index.php?option=com_configbox&view=customview&viewname=<name>. Create it with a migration.

(Built-in views — products, configurator pages, cart, user — already satisfy all three; see §5.)


How it works — the round trip

The Joomla router entry points are ConfigboxBuildRoute(&$query) and ConfigboxParseRoute(&$segments) (router.php), wrapped by ConfigboxRouter implementing Joomla 4+'s RouterInterface (build/parse/preprocess). Both initKenedo('com_configbox') first. On Joomla 4/5, bootComponent('com_configbox') returns the thin ConfigboxComponent wrapper, whose createRouter() instantiates ConfigboxRouter with the injected $app and $menu (kept by the class's constructor). See §"Joomla 4/5 component wrapper & router registration" below for how the wrapper works and what is deliberately left as pure Kenedo.

KLink::getRoute('index.php?option=com_configbox&view=<name>&serial=AAE8273')


ConfigboxBuildRoute(&$query) router.php
│ if no $query['view'] → return [] (NO SEF) ← why requirement #1 exists
│ resolve controller class from controller/view name (KenedoController::getControllerClass)
│ if controller missing → log warning, return []

ConfigboxController<Name>::getUrlSegments(&$query) your controller
│ find the menu item for view=customview&viewname=<name>
│ set $query['Itemid'], unset view/serial, return ['AAE8273']

/quote-follow-up/AAE8273

Parsing a URL (incoming request → controller)

GET /quote-follow-up/AAE8273


ConfigboxParseRoute(&$segments) router.php
│ read active Joomla Itemid → menu item data (KenedoRouterHelper::getJoomlaMenuItemData)
│ switch ($activeViewName):
│ 'customview' → viewname from the menu item → that controller's hooks:
│ getViewNameFromUrlSegments() → '<name>'
│ getSegmentMatching() → [0 => 'serial']
│ getSegmentParsing() → [] (identity)
│ fill $vars from segmentMatching (default branch: value as-is, or run a segmentParser)
│ $vars['view'] = '<name>'

view=<name> & serial=AAE8273


configbox.php → ConfigboxController<Name>::display() (task defaults to 'display')

The parse side has two families of cases in the big switch ($activeViewName) (router.php):

  • Built-in views with bespoke logic baked into router.php: user/userorder, productlisting, product, configuratorpage, cart. These map segment counts to view names and parameters directly (e.g. under a productlisting menu item, 1 segment ⇒ product, 2 segments ⇒ configuratorpage).
  • Everything else — the customview and default cases — which delegate to the controller's hooks (getViewNameFromUrlSegments / getSegmentMatching / getSegmentParsing). This is the extensible path your custom view uses.

There's also a no-active-menu fallback (URLs like /component/configbox/car/exterior) that deduces the view from the number of segments.

Key framework pieces (this repo)

PieceLocationRole
ConfigboxRouter + ConfigboxBuildRoute/ConfigboxParseRouterouter.phpJoomla router entry points
KenedoRouterHelper::getItemIdByLink()external/kenedo/helpers/router.php:71menu-item id for a link (published, type=component, client_id=0, language-aware)
KenedoRouterHelper::getJoomlaMenuItemData()…/helpers/router.php:412active menu item's query/view
KenedoRouterHelper::getProdId() / getPageId()…/helpers/router.php:131/159label → id (built-in product SEF)
KenedoController::getUrlSegments() / getViewNameFromUrlSegments() / getSegmentMatching() / getSegmentParsing()external/kenedo/classes/KenedoController.php:1021-1056base hooks you override (defaults are no-ops)
ConfigboxControllerCustomviewcontrollers/customview.phpgeneric custom-view dispatcher + builder
KLink::getRoute()external/kenedo/classes/KLink.phpbuilds the (SEF) URL via the platform
configbox.phpcomponent rootview=XConfigboxControllerX, runs task (default display)

Joomla 4/5 component wrapper & router registration

Status: CBX targets Joomla 4 and later only. This section records how the component and its router are registered on J4/J5, what was cleaned up when J3 support was dropped, and why the "modern component" wrapper is deliberately thin. Verified end-to-end on a live Joomla 5.4.7 install (frontend SEF pages render, admin loads, bootComponent() returns the wrapper).

The thin component wrapper (current mechanism)

com_configbox registers as a modern, namespaced Joomla 4/5 component through a small boundary adapter — without touching the Kenedo framework it runs on. The moving parts:

FileRole
administrator/components/com_configbox/services/provider.phpbinds ComponentInterfaceConfigboxComponent
administrator/components/com_configbox/src/Extension/ConfigboxComponent.phpthe wrapper: getDispatcher() + createRouter()
<namespace path="src">Joomla\Component\Configbox</namespace> in the admin manifestPSR-4 autoloading for the two files above

ConfigboxComponent implements ComponentInterface and RouterServiceInterface, and does as little as possible:

  • getDispatcher($app) returns a LegacyComponentDispatcher, whose entire job is to require the component's existing single entry file (components/com_configbox/configbox.php on the site, administrator/…/configbox.php on the admin). So configbox.phpinitKenedo()KenedoController boots exactly as before — for both applications.
  • createRouter($app, $menu) returns the existing global ConfigboxRouter (from components/com_configbox/router.php), a verbatim mirror of LegacyComponent::createRouter(). The plain RouterInterface router is kept as-is — no RouterView, no PSR-4 router class.

At SEF-routing time SiteRouter::getComponentRouter('com_configbox') calls $app->bootComponent('com_configbox'), which now returns ConfigboxComponent (a RouterServiceInterface), and calls its createRouter()ConfigboxRouter.

Before the wrapper, com_configbox had no services/provider.php, so bootComponent() fell back to Joomla's LegacyComponent, which loaded the same router.php and instantiated the same ConfigboxRouter. The wrapper makes the component a first-class registered ComponentInterface instead of relying on that fallback; the router and dispatch behaviour are unchanged. The ConfigboxRouter::__construct($app, $menu) added at the same time keeps the injected application/menu (they were previously discarded) so the router need not reach for JFactory/globals.

⚠️ Operational gotcha — the namespace cache. Joomla builds the PSR-4 map for namespaced extensions into administrator/cache/autoload_psr4.php by scanning component manifests, and only rebuilds it when that file is absent (see libraries/namespacemap.php). Adding or changing the <namespace> element on an already-installed component does nothing until that cache is cleared (delete the file, or use System → Clear Cache). If ConfigboxComponent "class not found" after a deploy, this is why.

Why the wrapper is thin (not a stock services/provider.php)

The wrapper binds ComponentInterface directly and overrides getDispatcher()/createRouter() by hand, rather than registering Joomla's stock MVCFactory / ComponentDispatcherFactory / RouterFactory service providers. That is deliberate: those factories resolve classes by PSR-4 convention that CBX does not follow. In particular, the default Joomla\CMS\Component\Router\RouterFactory::createRouter() hard-requires:

  • a PSR-4 class named Joomla\Component\Configbox\Site\Service\Router (not our global ConfigboxRouter), and
  • a 4-argument constructor ($app, $menu, $categoryFactory, $db) — the pattern core components satisfy by extends RouterView.

CBX uses neither Joomla MVC, Joomla categories, nor a RouterView-style router, so those stock factories have nothing to resolve. Delegating dispatch to LegacyComponentDispatcher and returning the existing ConfigboxRouter is the whole point — the component is a modern Joomla citizen at the boundary while its internals stay pure Kenedo.

What was removed when J3 support was dropped

Since only J4+ is supported, the following pre-J4 / pre-3.0 debris was deleted from router.php and KenedoRouterHelper:

RemovedWas atWhy it's dead on J4+
interface_exists('…RouterInterface') guard around the classrouter.php topRouterInterface always exists on J4+
str_ireplace(':', '-', …) segment normalizationConfigboxParseRouteJoomla 1.5 colon-in-segment quirk; irrelevant on J4+ (and the RouterLegacy shim, which we don't use, already handles :- itself)
doLegacyFixesBuildRoute() / doLegacyFixesParseRoute() (methods in helpers/router.php) + their two call sites (in router.php)KenedoRouterHelper, ConfigboxBuildRoute/ConfigboxParseRouteRewrote CB 2.6-era inbound URLs (view=grandorder→cart, category→configuratorpage, products→productlisting, cat_id→page_id, cborder_id→order_id)

⚠️ Behaviour change: old CB 2.6-style inbound URLs using those view names / parameters are no longer translated and will 404. This was an explicit decision for the major release — if any of those old links are still live externally, reintroduce a redirect at the web-server level rather than restoring the router methods.

Explicitly NOT done — full RouterView / MVC conversion

The wrapper makes CBX a registered, namespaced J4/J5 component, but it does not convert the component's internals to Joomla MVC. That deeper migration — real only if there is a concrete reason to adopt Joomla's RouterView — remains a separate, larger project and would require:

  • a namespaced MVCComponent + stock ComponentDispatcherFactory running actual Joomla controllers, instead of delegating to LegacyComponentDispatcherconfigbox.php → Kenedo;
  • rewriting ConfigboxBuildRoute/ConfigboxParseRoute as a RouterView subclass with RouterViewConfiguration view trees and rule classes (MenuRules/StandardRules/NomenuRules), re-expressing every controller's hand-rolled menu-matching and label logic.

RouterView is designed for the stock Joomla MVC + #__categories + core menu model. CBX deliberately runs on the bespoke Kenedo framework (configbox_strings EAV i18n, its own product/page hierarchy), so a faithful port carries high regression risk and cuts against the "don't force modern Joomla idioms into Kenedo code" guidance in CLAUDE.md. The wrapper exists precisely so this is not required: CBX lives within Joomla as a modern component without changing how it is built.


Step by step — giving a custom view a clean URL

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 ConfigboxBuildRoute returns [] and you keep index.php?...
KLink::getRoute('index.php?option=com_configbox&controller=bcquotelandingpage&task=display&serial=' . urlencode($serial), false, true);

KLink::getRoute($url, $encode = true, $secure = NULL) (delegates to the platform's getRoute):

  • 2nd arg $encode — default true returns &amp; (use when the URL goes straight into HTML); false returns raw & (use for JS, redirects, hrefs you assemble yourself, or email links).
  • 3rd arg $securetrue forces an absolute https:// URL (use for emails / off-page links); NULL/omitted gives a root-relative path.

view=<name> resolves to ConfigboxController<Name> and runs the default display task, so the page works 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 on your KenedoController subclass (base defaults are no-ops — KenedoController.php:1021):

HookSidePurpose / default
getUrlSegments(&$query)buildSet $query['Itemid'], unset consumed params, return the path segments. Default [].
getViewNameFromUrlSegments($segments)parseWhich view the segments resolve to. Default = the controller name.
getSegmentMatching($activeViewName, $segments)parseMap segment index → query-parameter name ([0 => 'serial']). Default [].
getSegmentParsing($activeViewName, $segments)parseOptional per-segment callable to transform a segment on the way in (e.g. base64-decode, slug→id). Default [] = use the value as-is.

Minimal example for a page keyed by a single serial:

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:

  • URL-safe ids go in the path raw. Serials matching ^[a-zA-Z0-9]+$ need no encoding and stay readable. Only base64-encode (and reverse it in getSegmentParsing()) when a segment can contain characters that aren't URL-safe.
  • The parse side's default/customview branch (router.php) fills $vars from getSegmentMatching: an empty parameter name means "ignore this segment"; a callable in getSegmentParsing is run on the segment; otherwise the raw segment value is used. A non-callable parser throws (logged).
  • For multi-segment URLs, return more entries from getUrlSegments and map each in getSegmentMatching — see configuratorpage (§5).

Alternative without a dedicated controller hook: KenedoRouterHelper::getSegmentsFromCustomView() / getSegmentMatchingFromCustomView() (…/helpers/router.php:333/370) centralize segment rules per viewname in the helper itself (the repo ships a bcshowcase/bcshowcases example there). Prefer putting the logic on the controller — hardcoding project view names into the shared framework helper is an anti-pattern.

3. Create the Joomla menu item (via a migration)

Kenedo finds the URL anchor by looking up a published menu item whose link is index.php?option=com_configbox&view=customview&viewname=<name> (getMenuItems() filters client_id=0 AND published=1 AND type='component', grouped by language, with */empty treated as language-independent — helpers/router.php:16). Without it, getItemIdByLink() returns nothing and you get no clean path.

Do not hand-edit the DB. Add an idempotent migration to the customization track, data/customization/updates/<version>.php. It runs automatically on the next page load (observers/System.phpConfigboxUpdateHelper::applyUpdates()), 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 — keep migrations defensive. (Full mechanics: ../../technical/com_configbox_migrations.md.)

<?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 rightmost child.
$db->setQuery("SELECT `id`, `rgt` FROM `#__menu` WHERE `lft` = 0");
$root = $db->loadObject();

if (!empty($root) && !empty($componentId)) {

$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
$item->menutype = 'system-seo'; // hidden SEO menu (routing only)
$item->title = 'Your Page';
$item->alias = 'your-page'; // becomes the URL path
$item->note = '';
$item->path = 'your-page'; // root-level ⇒ path == alias
$item->link = $link;
$item->type = 'component';
$item->published = 1;
$item->parent_id = (int)$root->id;
$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 makes re-runs (e.g. after a cleared failure flag) no-ops.
  • Nested-set safe — appending as the rightmost child of root only shifts root's rgt, so existing lft/rgt containment stays valid.
  • Portablecomponent_id and the root are looked up at runtime; no hard-coded ids, so the same script works on local/staging/production.
  • system-seo is a hidden menu used purely for routing. (Alternatively create it via the Joomla admin: Menus → New → CBX → 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, plus setPreparedQuery($sql, $params) for real prepared statements (:named/? placeholders, IN-list expansion — prefer it over manual escaping) — but no Joomla-style quote(), and transactions go by startTransaction/commitTransaction/rollbackTransaction. Use #__ as the prefix placeholder; if you escape manually, getEscaped() does not add surrounding quotes.


5. Built-in SEF: products & configurator pages (label-based)

CBX's own product/configurator URLs (e.g. the demo site's /en/demo-products/car/motor.html) are SEF'd with human-readable labels, not ids — worth understanding because it's the richest example and shows the label↔id translation:

  • Build (controllers/configuratorpage.php::getUrlSegments): tries progressively less specific menu items via getItemIdByLink() — an exact configuratorpage item, then a parent product item, then a parent/any productlisting item — setting Itemid and returning the product label and/or page label as segments. Labels come from the translation table (#__configbox_strings, product label type=17, page label type=18).
  • Parse (router.php, case 'product' / case 'configuratorpage'): translates the label segments back to ids with KenedoRouterHelper::getProdId() / getPageId() (which query #__configbox_strings for the matching label in the active language), and stashes the used label in $GLOBALS['productLabel']/['pageLabel'] so an outdated URL can be recovered (ConfigboxModelProduct::fixLabels()).

So a configurator URL resolves as: menu item (listing/product) → prod_id from product label → page_id from page label (scoped to that product). This is why product/page SEF aliases are set on the Product/Page admin forms (the translatable "SEF Segment" field), not as raw ids.

Other built-in views handled directly in router.php's parse switch: productlisting, cart, user (+ /orders/<id>userorder, /edit → profile edit), and an ipn/<controller>/<task>/<connector> fallback.


6. The generic API endpoint (/cb-api/…) — for 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), expect raw output (no site chrome), and shouldn't depend on a menu item existing. For these, CBX exposes a generic endpoint of the shape

[host]/[frontname]/[controller]/[task] e.g. /en/cb-api/cart/reloadCartSummary.html

decided in the platform-abstraction layer so the same call site works on Joomla / Magento 1+2 / WordPress / standalone. On Joomla the frontname is the hard-coded string cb-api.

Why not the menu router

The menu-based SEF above needs a published view=… menu item and routes through ConfigboxRouter. An XHR endpoint has neither a view nor a menu item — it carries controller, task and output_mode=view_only. So it uses a separate, self-contained mechanism: a system plugin that reserves a fixed leading URL segment before menu matching happens.

The platform primitive

PieceLocationRole
KenedoPlatform::getEndpointUrl($controller, $task, $encode = false)external/kenedo/interfaces/KenedoPlatform.phpinterface method; the sibling of getRoute() for endpoints
KLink::getEndpointUrl(...)external/kenedo/classes/KLink.phpstatic passthrough to the platform
Joomla implexternal/kenedo/platforms/joomla/general.phpbuilds index.php?option=com_configbox&controller=&task=&output_mode=view_only and runs it through getRoute() — the frontname plugin (below) turns that into /cb-api/… when SEF is on
Magento 1/2 implplatforms/magento{,2}/general.phpconfigbox/{controller}/{task} route path
WordPress / standalone implplatforms/{wordpress,standalone}/general.phpdelegate to their getRoute(); controller/task ride in the query

Contract: controller and task always appear in the URL on every platform (as path segments where the platform supports pretty URLs, otherwise as query parameters). When Joomla SEF is off, getEndpointUrl() falls back to the plain index.php?... query string automatically — callers can use it unconditionally.

The Joomla frontname router (system plugin)

Lives in the outer Joomla repo (not the component submodule): docroot/plugins/system/configbox/configbox.php (PlgSystemConfigbox). It reuses the plugin that was already there (the one that maps output_modeformat). Frontname constant: self::FRONTNAME = 'cb-api'.

In onAfterInitialise() — site app + SEF on only — it attaches two router rules:

$router->attachParseRule([$this, 'parseFrontname'], Router::PROCESS_BEFORE);
$router->attachBuildRule([$this, 'buildFrontname'], Router::PROCESS_BEFORE);

Parse (parseFrontname, PROCESS_BEFORE — before core SEF/menu parsing):

  • Falls through untouched unless the frontname segment is present → every other URL routes exactly as if the plugin didn't exist.
  • Skips an optional leading language segment to find cb-api (see multilingual note below), strips the sef_suffix (.html) off the last segment, then sets option/controller/task, output_mode=view_only, format=raw, and a default Itemid.
  • Consumes its part of the path (leaving only the language segment, if any) so menu matching is skipped and Router::parse() doesn't 404 on a leftover path.

Build (buildFrontname, PROCESS_BEFORE):

  • Only claims genuine endpoint URLs — those carrying controller + task and output_mode=view_only. This is the discriminator that separates the API layer from page navigation: many normal links (cart/addProductToCart, cart/checkoutCart, admin edit links) also carry a controller+task pair but no output_mode, and must keep their existing routes.
  • Produces cb-api/{controller}/{task} and strips the consumed vars (including option) so core buildSefRoute skips itself.
  • Must run at PROCESS_BEFORE, not DURING. Core's SEF build (SiteRouter::buildSefRoute) runs at the DURING stage and, for any option=com_configbox URL, calls the component router and then unset($query['option']) — turning it into a generic /component/configbox/… route. Core attaches that rule when the router is constructed, i.e. before this plugin's onAfterInitialise, so a DURING build rule here is ordered after buildSefRoute and never sees option (it is already gone) — the endpoint URL then silently falls back to /component/configbox/?controller=…. Running at BEFORE, this rule sees the full query and claims the URL first. (Regression fixed 2026-07; the rule was previously DURING and worked only by attach-order luck that the Joomla 5 upgrade removed.)
  • Multilingual ordering still holds at BEFORE. The Language Filter appends the /{sef}/ language segment in its BEFORE build rule (LanguageFilter::buildRule), and this plugin is ordered after the Language Filter (ensureRoutingOrder()), so its BEFORE rule runs after the Language Filter's — the path already carries /{lang}/ when cb-api/… is appended → /{lang}/cb-api/….

configbox.php (the component dispatcher) already reads option/controller/view/task from the request regardless of transport, so a parsed /cb-api/… request dispatches identically to the old query-string form.

The JS side (server.js)

helpers/view.php::getAmdLoaderJs() emits urlEndpointBase in the AMD config — a template:

"urlEndpointBase": "/en/cb-api/__CONTROLLER__/__TASK__.html"

server.js has a helper endpointUrl(controller, task) that substitutes the placeholders (or falls back to server.config.urlXhr when no template is present — e.g. SEF off). All XHR call sites in server.js (makeRequest, injectHtml, replaceHtml, checkoutCart, placeOrder, the login/logout /password helpers, …) route through it. The request body/params are unchanged, so the dispatcher reads controller/task from the path or the body either way.

server.min.js is regenerated from server.js by tools/minify-js.sh (same output as the PhpStorm Closure watcher) — never hand-edit the .min.js.

Plugin ordering vs the Language Filter (multilingual sites)

On a multilingual site the endpoint URL only builds correctly (/en/cb-api/…, not /cb-api/…/en/) when plg_system_configbox is ordered after plg_system_languagefilter, so the Language Filter prefixes the language before buildFrontname appends the route. This is guaranteed three ways:

  • Install: installer.php::postflight() sets the ordering (ensureRoutingOrder).
  • Runtime self-heal: PlgSystemConfigbox::ensureRoutingOrder() re-checks on every request and auto-corrects the DB ordering if an admin reordered the plugins (takes effect from the next request); it throws if the correction can't be applied, rather than silently emitting broken URLs.
  • The parse rule also skips a leading language segment itself, so incoming /en/cb-api/… resolves even in the one request before a reorder self-heals.

Suffix & language behaviour (by design)

On a SEF+suffix+multilingual site the endpoint URLs come out as /{lang}/cb-api/{controller}/{task}.html. Both the .html suffix and the language prefix are Joomla's standard, unavoidable SEF pipeline behaviour — the Language Filter prefixes every SEF URL on build (no per-URL opt-out) and core appends the suffix to any route not ending in / or index.php. This is accepted as-is: the endpoint works correctly with both, and they match every other SEF URL on the site. (Truly suffix-free / language-free endpoint URLs would require either a trailing-slash route or bypassing the SEF pipeline with a dedicated PHP entry point — deliberately not done here.)

Frontname collision caveat

cb-api is hard-coded and claimed before menu matching, so a menu item whose alias is cb-api would be silently unreachable. The hyphenated, API-flavoured name makes this very unlikely; there is no install-time collision check.


Verifying

After the migration has run (load any front-end page once), confirm the round trip. This repo's DB prefix is sltxh_ (see configuration.php $dbprefix) and the dev URL is https://configbox-joomla.local:

# Version bumped, no failure flag
mysql -e "SELECT \`key\`,\`value\` FROM sltxh_configbox_system_vars
WHERE \`key\` IN ('latest_customization_update_version','failed_update_detected');"

# Menu item exists and is published
mysql -e "SELECT id,alias,link,published,lft,rgt FROM sltxh_menu WHERE link LIKE '%viewname=YOURVIEW%';"

# Nested set still consistent (expect 0)
mysql -e "SELECT COUNT(*) AS broken FROM sltxh_menu WHERE lft >= rgt;"

# Parse side: the pretty path loads the page (HTTP 200, not a redirect/404)
curl -k -s -o /dev/null -w "%{http_code}\n" "https://configbox-joomla.local/your-page/SOMEID"

# Build side: rendered pages emit the clean path, not index.php?...
curl -k -s "https://configbox-joomla.local/your-page/SOMEID" | grep -oE "your-page/[A-Za-z0-9]+" | head

Requires Joomla SEF on ($sef, $sef_rewrite in configuration.php — both 1 here; $sef_suffix=1 adds the .html suffix seen on the demo URLs).

Verifying the /cb-api/… endpoint

Automated smoke test (run this first). tests/check-cb-api.php drives a running site over HTTP and asserts all three legs of the contract — the build template is cb-api, the endpoint parses to raw output, and ordinary nav links are not captured. It exits non-zero (with a diagnostic) when the routing silently degrades to query-string URLs, which is exactly how this last regressed on the Joomla 4→5 upgrade. Run it after any change to the plugin/router and after every Joomla core upgrade:

php tests/check-cb-api.php https://configbox-joomla.local:7890 # base URL; defaults to the dev host

The manual curl checks below verify the same things by hand:

# Endpoint resolves directly — HTTP 200, raw output (no <!DOCTYPE), zero redirects.
# reloadCartSummary is a safe read-only view_only task.
curl -k -s -o /dev/null -w "%{http_code} redirects=%{num_redirects}\n" \
"https://configbox-joomla.local/en/cb-api/cart/reloadCartSummary.html"

# It returns the bare component output, not the full site template.
curl -k -s "https://configbox-joomla.local/en/cb-api/cart/reloadCartSummary.html" | grep -c DOCTYPE # expect 0

# Fall-through: a normal page is unaffected by the frontname rules.
curl -k -s -o /dev/null -w "%{http_code}\n" "https://configbox-joomla.local/en/cart" # expect 200 full page

# Bad tail 404s via the component dispatcher (not a silent menu page).
curl -k -s -o /dev/null -w "%{http_code}\n" "https://configbox-joomla.local/en/cb-api/nope/foo.html" # expect 404

# Plugin ordering is correct (configbox AFTER languagefilter) on a multilingual site.
mysql -e "SELECT element,ordering FROM sltxh_extensions
WHERE folder='system' AND element IN ('configbox','languagefilter');"

In the browser, load a CBX page and confirm server.config.urlEndpointBase is /{lang}/cb-api/__CONTROLLER__/__TASK__.html, then that a real server.makeRequest(...) fires a POST to /{lang}/cb-api/{controller}/{task}.html (Network panel).


Gotchas / checklist

  • Link generated with view=<name> (not controller=&task=). No view ⇒ no SEF (router.php:64).
  • Controller overrides getUrlSegments(), getViewNameFromUrlSegments(), getSegmentMatching().
  • getSegmentMatching() keys line up with the segment indexes getUrlSegments() returns.
  • Published menu item (type=component, client_id=0, view=customview&viewname=<name>) exists, in the right language (or *).
  • 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 3rd arg for absolute https.
  • AJAX still uses controller=&task=; only page links need view=.

Reference implementations

In this repo:

  • controllers/configuratorpage.php — multi-segment, label-based product/page SEF (the richest example; pairs with router.php case 'configuratorpage').
  • controllers/customview.php — the generic custom-view dispatcher: its execute() resolves the real controller from viewname and runs its task (default display); its getUrlSegments() first asks the real controller, then falls back to the view=customview&viewname=<name> menu-item lookup.
  • router.php parse switch — product, productlisting, user/userorder, cart built-ins.

For the /cb-api/… endpoint (§6):

  • docroot/plugins/system/configbox/configbox.php (outer repo) — parseFrontname/buildFrontname
    • ensureRoutingOrder runtime self-heal.
  • docroot/plugins/system/configbox/installer.php — install-time ordering enforcement.
  • external/kenedo/interfaces/KenedoPlatform.php + classes/KLink.php + platforms/*/general.php — the getEndpointUrl() primitive.
  • assets/javascript/server.js (endpointUrl() helper) + helpers/view.php (urlEndpointBase in the AMD config).

Downstream customization-layer examples (another project, same hooks):

  • 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).