# SEF (clean) URLs for CBX / Kenedo on Joomla

> > 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…

Source: CBX documentation, version 4.0 preview (unreleased). Canonical page: https://docs.configbox.at/docs/4.0-preview/platform/joomla/sef_urls. Last updated 2026-08-01.

---
> **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 hooks** — `getUrlSegments()`,
   `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.

### 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 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)

| 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 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.php` → `initKenedo()` → `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`:

| 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` + stock `ComponentDispatcherFactory` running actual Joomla controllers,
  instead of delegating to `LegacyComponentDispatcher` → `configbox.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

### 1. Generate links with `view=`

Build links through `KLink::getRoute()` using `view=<name>`:

```php
// 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 `$secure`** — `true` 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`):

| 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`:

```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:
- **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.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
<?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.
- **Portable** — `component_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

| 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:

```php
$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`:

```bash
# 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:

```bash
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:

```bash
# 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).
