SEF (clean) URLs for CBX / Kenedo on Joomla
- Version
- 4.0 preview
- Updated
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 JSserver.jslayer now uses instead of the rawindex.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.
- Generate the link with
view=<name>— nevercontroller=<name>&task=display. The router bails immediately on any query that has noview(router.php:64). - The controller implements the SEF router hooks —
getUrlSegments(),getViewNameFromUrlSegments(),getSegmentMatching()(and optionallygetSegmentParsing()). - 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.
Building a URL (link generation → pretty path)
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 aproductlistingmenu item, 1 segment ⇒product, 2 segments ⇒configuratorpage). - Everything else — the
customviewanddefaultcases — 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)
| Piece | Location | Role |
|---|---|---|
ConfigboxRouter + ConfigboxBuildRoute/ConfigboxParseRoute | router.php | Joomla router entry points |
KenedoRouterHelper::getItemIdByLink() | external/kenedo/helpers/router.php:71 | menu-item id for a link (published, type=component, client_id=0, language-aware) |
KenedoRouterHelper::getJoomlaMenuItemData() | …/helpers/router.php:412 | active menu item's query/view |
KenedoRouterHelper::getProdId() / getPageId() | …/helpers/router.php:131/159 | label → id (built-in product SEF) |
KenedoController::getUrlSegments() / getViewNameFromUrlSegments() / getSegmentMatching() / getSegmentParsing() | external/kenedo/classes/KenedoController.php:1021-1056 | base hooks you override (defaults are no-ops) |
ConfigboxControllerCustomview | controllers/customview.php | generic custom-view dispatcher + builder |
KLink::getRoute() | external/kenedo/classes/KLink.php | builds the (SEF) URL via the platform |
configbox.php | component root | view=X → ConfigboxControllerX, 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:
| File | Role |
|---|---|
administrator/components/com_configbox/services/provider.php | binds ComponentInterface → ConfigboxComponent |
administrator/components/com_configbox/src/Extension/ConfigboxComponent.php | the wrapper: getDispatcher() + createRouter() |
<namespace path="src">Joomla\Component\Configbox</namespace> in the admin manifest | PSR-4 autoloading for the two files above |
ConfigboxComponent implements ComponentInterface and RouterServiceInterface, and does as
little as possible:
getDispatcher($app)returns aLegacyComponentDispatcher, whose entire job is torequirethe component's existing single entry file (components/com_configbox/configbox.phpon the site,administrator/…/configbox.phpon the admin). Soconfigbox.php→initKenedo()→KenedoControllerboots exactly as before — for both applications.createRouter($app, $menu)returns the existing globalConfigboxRouter(fromcomponents/com_configbox/router.php), a verbatim mirror ofLegacyComponent::createRouter(). The plainRouterInterfacerouter is kept as-is — noRouterView, 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, sobootComponent()fell back to Joomla'sLegacyComponent, which loaded the samerouter.phpand instantiated the sameConfigboxRouter. The wrapper makes the component a first-class registeredComponentInterfaceinstead of relying on that fallback; the router and dispatch behaviour are unchanged. TheConfigboxRouter::__construct($app, $menu)added at the same time keeps the injected application/menu (they were previously discarded) so the router need not reach forJFactory/globals.
⚠️ Operational gotcha — the namespace cache. Joomla builds the PSR-4 map for namespaced extensions into
administrator/cache/autoload_psr4.phpby scanning component manifests, and only rebuilds it when that file is absent (seelibraries/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). IfConfigboxComponent"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 globalConfigboxRouter), and - a 4-argument constructor
($app, $menu, $categoryFactory, $db)— the pattern core components satisfy byextends 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:
| Removed | Was at | Why it's dead on J4+ |
|---|---|---|
interface_exists('…RouterInterface') guard around the class | router.php top | RouterInterface always exists on J4+ |
str_ireplace(':', '-', …) segment normalization | ConfigboxParseRoute | Joomla 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/ConfigboxParseRoute | Rewrote 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+ stockComponentDispatcherFactoryrunning actual Joomla controllers, instead of delegating toLegacyComponentDispatcher→configbox.php→ Kenedo; - rewriting
ConfigboxBuildRoute/ConfigboxParseRouteas aRouterViewsubclass withRouterViewConfigurationview 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
1. Generate links with 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 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— defaulttruereturns&(use when the URL goes straight into HTML);falsereturns raw&(use for JS, redirects, hrefs you assemble yourself, or email links). - 3rd arg
$secure—trueforces an absolutehttps://URL (use for emails / off-page links);NULL/omitted gives a root-relative path.
view=<name>resolves toConfigboxController<Name>and runs the defaultdisplaytask, so the page works 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 on your KenedoController subclass (base defaults are no-ops — KenedoController.php:1021):
| Hook | Side | Purpose / default |
|---|---|---|
getUrlSegments(&$query) | build | Set $query['Itemid'], unset consumed params, return the path segments. Default []. |
getViewNameFromUrlSegments($segments) | parse | Which view the segments resolve to. Default = the controller name. |
getSegmentMatching($activeViewName, $segments) | parse | Map segment index → query-parameter name ([0 => 'serial']). Default []. |
getSegmentParsing($activeViewName, $segments) | parse | Optional 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 ingetSegmentParsing()) when a segment can contain characters that aren't URL-safe. - The parse side's
default/customviewbranch (router.php) fills$varsfromgetSegmentMatching: an empty parameter name means "ignore this segment"; a callable ingetSegmentParsingis 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
getUrlSegmentsand map each ingetSegmentMatching— seeconfiguratorpage(§5).
Alternative without a dedicated controller hook:
KenedoRouterHelper::getSegmentsFromCustomView()/getSegmentMatchingFromCustomView()(…/helpers/router.php:333/370) centralize segment rules perviewnamein the helper itself (the repo ships abcshowcase/bcshowcasesexample 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.php → ConfigboxUpdateHelper::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 existinglft/rgtcontainment stays valid. - Portable —
component_idand the root are looked up at runtime; no hard-coded ids, so the same script works on local/staging/production. system-seois 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 aKenedoDatabasewrapper, not Joomla's driver. It hassetQuery/query/loadResult/loadObject/getEscaped/getQuoted/insertObject, plussetPreparedQuery($sql, $params)for real prepared statements (:named/?placeholders, IN-list expansion — prefer it over manual escaping) — but no Joomla-stylequote(), and transactions go bystartTransaction/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 viagetItemIdByLink()— an exactconfiguratorpageitem, then a parentproductitem, then a parent/anyproductlistingitem — settingItemidand returning the product label and/or page label as segments. Labels come from the translation table (#__configbox_strings, product labeltype=17, page labeltype=18). - Parse (
router.php,case 'product'/case 'configuratorpage'): translates the label segments back to ids withKenedoRouterHelper::getProdId()/getPageId()(which query#__configbox_stringsfor 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
| Piece | Location | Role |
|---|---|---|
KenedoPlatform::getEndpointUrl($controller, $task, $encode = false) | external/kenedo/interfaces/KenedoPlatform.php | interface method; the sibling of getRoute() for endpoints |
KLink::getEndpointUrl(...) | external/kenedo/classes/KLink.php | static passthrough to the platform |
| Joomla impl | external/kenedo/platforms/joomla/general.php | builds 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 impl | platforms/magento{,2}/general.php | configbox/{controller}/{task} route path |
| WordPress / standalone impl | platforms/{wordpress,standalone}/general.php | delegate 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_mode → format). 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 thesef_suffix(.html) off the last segment, then setsoption/controller/task,output_mode=view_only,format=raw, and a defaultItemid. - 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+taskandoutput_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 acontroller+taskpair but nooutput_mode, and must keep their existing routes. - Produces
cb-api/{controller}/{task}and strips the consumed vars (includingoption) so corebuildSefRouteskips itself. - Must run at PROCESS_BEFORE, not DURING. Core's SEF build (
SiteRouter::buildSefRoute) runs at the DURING stage and, for anyoption=com_configboxURL, calls the component router and thenunset($query['option'])— turning it into a generic/component/configbox/…route. Core attaches that rule when the router is constructed, i.e. before this plugin'sonAfterInitialise, so a DURING build rule here is ordered afterbuildSefRouteand never seesoption(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}/whencb-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.jsis regenerated fromserver.jsbytools/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>(notcontroller=&task=). Noview⇒ no SEF (router.php:64). - Controller overrides
getUrlSegments(),getViewNameFromUrlSegments(),getSegmentMatching(). -
getSegmentMatching()keys line up with the segment indexesgetUrlSegments()returns. - Published menu item (
type=component,client_id=0,view=customview&viewname=<name>) exists, in the right language (or*). - 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,true3rd arg for absolute https. - AJAX still uses
controller=&task=; only page links needview=.
Reference implementations
In this repo:
controllers/configuratorpage.php— multi-segment, label-based product/page SEF (the richest example; pairs withrouter.phpcase 'configuratorpage').controllers/customview.php— the generic custom-view dispatcher: itsexecute()resolves the real controller fromviewnameand runs itstask(defaultdisplay); itsgetUrlSegments()first asks the real controller, then falls back to theview=customview&viewname=<name>menu-item lookup.router.phpparse switch —product,productlisting,user/userorder,cartbuilt-ins.
For the /cb-api/… endpoint (§6):
docroot/plugins/system/configbox/configbox.php(outer repo) —parseFrontname/buildFrontnameensureRoutingOrderruntime self-heal.
docroot/plugins/system/configbox/installer.php— install-time ordering enforcement.external/kenedo/interfaces/KenedoPlatform.php+classes/KLink.php+platforms/*/general.php— thegetEndpointUrl()primitive.assets/javascript/server.js(endpointUrl()helper) +helpers/view.php(urlEndpointBasein the AMD config).
Downstream customization-layer examples (another project, same hooks):
controllers/bccoldquote.php+ menu aliascold-quote(base64 segment +getSegmentParsing).controllers/bcquotelandingpage.php+ menu aliasquote-follow-up+updates/0.5.46.php(raw segment).