# KenedoController, in Detail

> A deep reference for KenedoController — the front-controller / request-dispatch layer of CBX's bespoke "Kenedo" MVC framework — plus the minimum of KenedoMod…

Source: CBX documentation, version 3.x (released). Canonical page: https://docs.configbox.at/docs/technical/kenedo_controller. Last updated 2026-08-03.

---
A deep reference for **`KenedoController`** — the front-controller / request-dispatch layer of CBX's
bespoke "Kenedo" MVC framework — plus the minimum of `KenedoModel` and `KenedoView` needed to see how the
three connect. The goal is that a new hire can **extend `KenedoController` itself** (add a base task, change
dispatch/authorization) with confidence, and a third-party integrator can understand what runs *underneath*
a custom controller they drop into `data/customization/controllers/`.

This is the sibling of `com_configbox_kenedo_view.md` (the view layer). It complements
`com_configbox_kenedo_mvc.md` (the whole-MVC overview) — where that doc summarizes the controller in one
section with a task table, this one is the full picture. All paths are relative to
`docroot/components/com_configbox/`; the framework lives under `external/kenedo/`. Line references track
current master — when one has drifted, search for the method name.

The base class is `external/kenedo/classes/KenedoController.php` (1471 lines). Line references below are to
it unless another file is named.

---

## 0. Mental model

A `KenedoController` is a **front controller**: one class per controller name, exposing **tasks** (public
methods) that a request selects by name. The base class ships a **complete CRUD task set** —
`display`, `edit`, `store`, `apply`, `delete`, `copy`, `publish`, `storeOrdering`, `cancel`, the JSON read
tasks `getRecord`/`getRecords`, plus deprecated AJAX aliases — so a concrete controller usually adds
**no task code at all**. It just wires three things:

1. **`getDefaultModel()`** — the `KenedoModel` the tasks read/write (or `NULL`).
2. **`getDefaultView()` / `getDefaultViewList()` / `getDefaultViewForm()`** — the `KenedoView`s the display
   tasks render (or `NULL`).
3. Optional **custom tasks** (extra public methods) for anything the base set doesn't cover.

Key ideas:

- **A "task" is literally a public method.** `execute($task)` is a reflection-guarded dispatcher: it checks
  the method exists and is `public`, then calls `$this->$task()`. Nothing else registers a task.
- **Controllers are true singletons**, resolved by class name through `getController()` (**unlike**
  `KenedoView`, whose "singleton" is vestigial — see `com_configbox_kenedo_view.md` §1.1). Core-first
  resolution; the customization dir only adds *new* controllers.
- **The controller is thin; the model does the work.** `store()`/`delete()`/`copy()`/`publish()` each
  parse the request, delegate to the model, purge cache, and emit a JSON response.
- **Display tasks hand off to a view.** `display()`/`edit()` pick a view, set its `listing` flag, and call
  `wrapViewAndDisplay()` — the seam into `KenedoView` (§4, and `com_configbox_kenedo_view.md` §7).
- **Output is buffered and post-processed.** A task echoes into an output buffer that the entry file passes
  through observers and the platform's renderer — or, if the task set `redirectUrl`, redirects instead.

The rest of this document: the request lifecycle (§1), resolution & naming (§2), `execute()` (§3), the
display tasks and the view handoff (§4), the write tasks in depth — `store` is the spine (§5), the other
tasks (§6), authorization (§7), how the model and view connect (§8), and extending the controller (§9).

---

## 1. The request lifecycle (entry → task → output)

Entry file `configbox.php`:

```php
initKenedo('com_configbox');                                             // :9  boot the framework
$component      = KRequest::getKeyword('option', 'com_configbox');       // :14
$controllerName = KRequest::getKeyword('controller', '');                // :15
$viewName       = KRequest::getKeyword('view', '');                      // :16
$task           = KRequest::getKeyword('task', 'display');               // :17  default task

if ($controllerName || $viewName) {
    $className  = KenedoController::getControllerClass($component, $controllerName, $viewName);  // :21
    // 404 if !controllerExists($className)                              // :23-44
    $controller = KenedoController::getController($className);           // :46  singleton
    ob_start();                                                          // :49
    $controller->execute($task);                                        // :52  run the task
    $output = ob_get_clean();                                           // :55
    if ($controller->redirectUrl) { $controller->redirect(); }          // :58  task asked to redirect
    else {
        KenedoObserver::triggerEvent('onBeforeRender', array(&$output)); // :63  observers can rewrite output
        KenedoPlatform::p()->renderOutput($output);                     // :66  platform emits it
    }
}
```

So the flow is: **request keywords → class name → singleton controller → `execute(task)` → buffered output
→ (redirect | observers + render)**. The controller never prints to the browser directly; it echoes into
the buffer (`ob_start` here, plus per-task `ob_start` in view rendering). The default task is `display`.
(The whole-MVC version of this is `com_configbox_kenedo_mvc.md` §1.)

---

## 2. Resolution & naming

### 2.1 Class name from the request — `getControllerClass()` (`:207`)

```php
$className = ucfirst(strtolower(substr($component, 4))) . 'Controller' . ucfirst(strtolower($namePart));
```

`$namePart` is the `controller` request keyword, or the `view` keyword if no controller was given.
So `option=com_configbox&controller=admincountries` → **`ConfigboxControllerAdmincountries`**.
If both are empty it throws a 400.

### 2.2 Loading the class — `getController()` (`:79`) — a **true** singleton

```php
if (!isset(self::$instances[$className])) {
    // component + filename from the class name (strip "Controller")
    $regularPath = <component>/controllers/<name>.php;                  // core
    $customPath  = data/customization/controllers/<name>.php;          // customization
    // core-first: use $regularPath if it exists, else $customPath      // :107-114
    require_once($path);
    self::$instances[$className] = new $className($component);          // :134
}
return self::$instances[$className];                                    // :139
```

Two things to internalize:
- **Core-first resolution.** A file in `data/customization/controllers/` only loads when core has **no**
  same-named controller — so the customization folder adds *new* controllers, it does **not** shadow a
  core one. To change an existing core controller's behavior, use `system_overrides/`. (Same rule as
  models and view *classes*; see `com_configbox_kenedo_mvc.md` §7.)
- **Real singleton.** Unlike `KenedoView::getView()` (which returns a fresh instance every call), the
  controller registry `self::$instances` is actually populated and reused — one instance per class name
  per request.

There's a legacy `Cbcheckout→Configbox` fallback (`MERGELEGACY`, `:127-132`): if `ConfigboxController<X>`
isn't defined but `CbcheckoutController<X>` is, the old class is used and a legacy-call warning is logged.
`controllerExists()` (`:148`) mirrors this resolution for the entry file's 404 check.

### 2.3 The naming convention (CRUD quad)

The plural/singular convention pairs a controller with its two views and a model (worked example:
Countries). The **plural** controller owns everything:

| File | Class | Role |
|---|---|---|
| `controllers/admincountries.php` | `ConfigboxControllerAdmincountries` | the controller |
| `models/countries.php` | `ConfigboxModelCountries` | data + property definitions |
| `views/admincountries/view.html.php` | `ConfigboxViewAdmincountries` | the **list** view (plural) |
| `views/admincountry/view.html.php` | `ConfigboxViewAdmincountry` | the **form** view (singular) |

Note the asymmetry: **model files and classes lost the `admin` prefix** (`models/countries.php`,
`ConfigboxModelCountries` — a shim in `KenedoModel::getModel()` still resolves old
`ConfigboxModelAdmin*` names with a deprecation log), while **controllers and views keep it** — the
prefix is what `isAuthorized()` keys the admin permission gate on (§7), so it stays meaningful there.

`getControllerNameFromClass()` (`:240`) is the inverse (`ConfigboxControllerAdmincountries` →
`admincountries`); tasks use it to build redirect URLs back to themselves.

---

## 3. `execute()` — how a task is dispatched (`:251`)

`execute($task)` is the **only** thing that turns a request keyword into a method call, and it's guarded:

```php
if (method_exists($this, $task) == false)                    throw 'Task not found' (logged);   // :254
$reflection = new ReflectionMethod($this, $task);
if ($reflection->isPublic() == false)                        throw 'Task not found' (logged);   // :269
$this->executedTask = $task;
$this->$task();                                                                                  // :287
```

Consequences you must design around:
- **A task is a public method.** To add a task, add a `public function myTask()`. To *hide* a helper from
  the request surface, make it `protected`/`private` — `execute()` refuses non-public methods (that's why
  `afterStore`, `wrapViewAndDisplay`, `setRedirect`, `purgeCache`, `abortUnauthorized` are `protected`).
- **No per-task ACL here.** `execute()` does *not* check authorization — each task calls `isAuthorized()`
  itself (§7). If you add a task that mutates data, **you** must call `$this->isAuthorized() or
  $this->abortUnauthorized();` first, like the built-ins do.
- **Unknown/private task → logged exception**, surfaced as "Task not found". (The entry file's catch
  suppresses the error-log spam for this specific 404-ish case, `configbox.php:76`.)

---

## 4. The display tasks & the view handoff

### 4.1 `display()` (`:312`) and `edit()` (`:336`)

These are the read tasks. They differ only in which view they pick and the `listing` flag they set:

```php
function display() {                                    function edit() {
    $this->isAuthorized() or $this->abortUnauthorized();    $this->isAuthorized() or $this->abortUnauthorized();
    $view = $this->getDefaultView();                        $view = $this->getDefaultViewForm();
    $view->listing = true;                                  $view->listing = false;
    $this->wrapViewAndDisplay($view);                       $this->wrapViewAndDisplay($view);
}                                                       }
```

The `listing` flag is the switch the view branches on: `true` → the view renders the model's **list**,
`false` → the **edit form** (`KenedoView::prepareTemplateVars()`, see `com_configbox_kenedo_view.md` §1.2
and §2). `display()` is the default task (§1), so a plain `?option=…&controller=admincountries` shows the
list.

### 4.2 `wrapViewAndDisplay()` (`:1265`) — the output-mode chrome

```php
$outputMode = KenedoPlatform::p()->getOutputMode();
if      ($outputMode == 'view_only')                          $view->display();   // bare (AJAX)
elseif  ($outputMode == 'in_html_doc')                        $view->display();   // bare (embed)
elseif  (strpos($view->view,'admin') === 0 && $view->view != 'admin') {
    $wrapper = KenedoView::getView('ConfigboxViewAdmin');
    $wrapper->contentHtml = $view->getHtml();                                     // admin shell wraps it
    $wrapper->display();
}
else                                                          $view->display();
```

So an `admin*` view gets wrapped in the admin chrome (`ConfigboxViewAdmin`: menu + frame) by having its
`getHtml()` string embedded — a controller embedding a view via `getHtml()`, the same idiom views use to
embed each other (`com_configbox_kenedo_view.md` §3.3). AJAX (`view_only`) and component-embed
(`in_html_doc`) render the view bare.

### 4.3 The minimal concrete controller

A CRUD controller is the four view/model factories and nothing else
(`controllers/admincountries.php`, full file):

```php
class ConfigboxControllerAdmincountries extends KenedoController {
    protected function getDefaultModel()    { return KenedoModel::getModel('ConfigboxModelCountries'); }
    protected function getDefaultView()     { return $this->getDefaultViewList(); }             // display → list
    protected function getDefaultViewList() { return KenedoView::getView('ConfigboxViewAdmincountries'); }
    protected function getDefaultViewForm() { return KenedoView::getView('ConfigboxViewAdmincountry'); }  // edit → form
}
```

All four factories are `abstract` on the base class (`:38-65`), so every controller must implement them —
return `NULL` for the ones it doesn't use (e.g. a pure-AJAX controller with no views).

---

## 5. `store()` — the spine of all saving (`:371`)

Every save (`store`, `apply`, `storeAndNew`) routes through `store()`. The controller's own contribution
is deliberately small — obtain a `$data` object from the request, then hand it to the **shared store
pipeline** — and it always answers with **JSON**:

```php
$this->isAuthorized() or $this->abortUnauthorized();          // :374
$model = $this->getDefaultModel();                            // :377  (throws if NULL)
$this->seedRequestFromExistingRecord($model);                 // pre-pipeline step
$data  = $model->getDataFromRequest();                        // request → normalized data object
try {
    // prepareForStorage / isInsert / validateData / store / cache purge, in one place:
    $result = ConfigboxEntityApiHelper::runStorePipeline($model, $data);   // :411
}
catch (KenedoValidationException $e) {                        // → 422 + one issue per bad field
    $this->sendValidationFailure($e);            return;
}
catch (KenedoSystemException $e) {                            // → 500 + meta.logIdentifier, no detail
    $this->sendSystemFailure($e, 'STORE_FAILED', …);  return;
}
$this->afterStore($success);                                  // controller hook (override me)
$this->purgeCache();                                          // customization seam; pipeline already purged
$payload = ['id' => …, 'wasInsert' => …, 'record' => $model->getRecord($recordId)];
($isInsert) ? ConfigboxApiResponse::created($payload)         // 201 on insert
            : ConfigboxApiResponse::ok($payload);             // 200 on update      :464
```

**One pipeline, shared with every other surface.** `runStorePipeline()`
(`helpers/entityapi.php:649`) is `prepareForStorage()` → `isInsert()` → `validateData()` →
`store()` → cache purge, and it is the same call the REST controller, the MCP tools and any
in-process caller run. Only how the `$data` object is *obtained* differs between surfaces — a form
post reads the request, an API call reads a JSON body — and that difference sits above the pipeline,
never inside it. (`isInsert()` is asked after `prepareForStorage()` and before `store()` on purpose:
that window is the only place the answer is right, and getting it wrong turns a 201 into a 200.)

**The two pre-pipeline steps** normalize what a request may carry (`:488-553`):

- **`seedRequestFromExistingRecord()`** (`:488`) — **partial-update semantics**: on an update, every
  field the caller did *not* send is seeded from the stored record, so "not sent" means "unchanged".
  Without it, a client sending only `{"title": …}` would blank every other field to its default (the
  first symptom being a NOT NULL column complaining). A field the caller *did* send wins — including
  when it was sent empty, which is how you clear a value. Inserts are left alone: defaults are the
  right answer there.

There is no `=== false` anywhere in that flow, because none of those methods returns `false` any more:
they return their value or throw, and the exception's TYPE is what decides the status code. The two
`send*Failure()` helpers are where every task's answer is shaped, so they cannot drift apart. Each
pipeline step loops the model's properties — the property model is the real engine (see
`com_configbox_kenedo_mvc.md` §3–§4).

**The response is `201` (insert) / `200` (update) with `{id, wasInsert, record}` — and no
`redirectUrl`.** Storing is all this task does; where to go afterwards is entirely the client's
decision, and the client already has what it needs (a detail form carries its own return URL).
Computing navigation server-side meant the same endpoint had to know whether it was being called by a
form, a modal or a script.

`apply()` (`:680`) and `storeAndNew()` (`:689`) are both **just `store()`** — the difference is entirely
client-side (the JS reads the `task` keyword and `wasInsert` and decides whether to reopen a blank form,
stay put, or navigate). `afterStore($success)` (`:698`) is an **empty protected hook** — the canonical
place to run extra logic after a save without reimplementing `store()`.

> **How a save travels.** The edit form is posted as **`FormData` over XHR** to the form's own action
> (`assets/javascript/server.js`, `submitForm()`), so file uploads ride along with the rest of the
> fields and a 422's validation messages arrive as a readable response. The old hidden-iframe submit —
> which existed for exactly the file-upload reason — is gone; if you meet it described in an old
> customization, that story is dead.

---

## 6. The rest of the task catalog

All follow the same shape: **authorize → parse `id`/`ids` → delegate to the model → `purgeCache()` →
respond (always JSON — see "How a task ends")**.

| Task (line) | What it does | Response |
|---|---|---|
| `getRecord()` | The read half the write tasks always implied. Authorizes, takes `id` (or the first of `ids`/`cid`), optional `language`, reads via `$model->getRecord()`. The record comes back **as the model built it**, minus the `apiSensitive` fields — a translatable carries the requested language in its plain key and every active language in the flat `title_en_GB` keys. `ConfigboxApiRecord::project()` does the withholding and nothing else. | `200` + `data:{record}`; `400 NO_RECORD_SELECTED` / `400 UNKNOWN_LANGUAGE`; `404 RECORD_NOT_FOUND`. |
| `getRecords()` (`:764`) | A page of records. `limit` defaults to `RECORDS_DEFAULT_LIMIT` (50) and is **clamped** to `RECORDS_MAX_LIMIT` (200) whatever the caller asks; `offset` floors at 0. `filters` pass through to the model's own filtering (the names the admin list uses). `data` is always `{items, total, offset, limit}` — `total` counts before paging so a caller can size a pager without a second request. Projection as `getRecord()`. | `200` + `data:{items, total, offset, limit}`; `400 UNKNOWN_LANGUAGE`. |
| `delete()` (`:836`) | Parses `ids`/`id`/legacy `cid` via `getRequestedRecordIds()`, then runs **the same pipeline as every other surface**: `ConfigboxEntityApiHelper::deleteManyForModel()` (delete + cache forgetting live there, shared with REST and MCP). Guards empty selection. | **Always** `ConfigboxApiResponse`: `200` + `data:{ids, deleted}`; `400` if nothing selected; **`409 RECORD_IN_USE`** when a record is still referenced (the model's own "linked with these records" explanation travels). `show_list` and `quickedit` are gone. |
| `ajaxDelete()` | **Deprecated alias** — calls `delete()`. It existed only to force the JSON branch, which is now the only branch. | As `delete()`. |
| `copy()` (`:898`) | Deep-clones record(s) **and their child records/rules/calcs** via `$model->copy()`, in one outer transaction. The clone's `title`/`name` gets a localized " (`KText::_('COPY_NOUN')`)" suffix per active language. | `201` + `data:{ids, newId, redirectUrl}` (`redirectUrl` is **advisory** — the server does not redirect); `400` if nothing selected; **`404 RECORD_NOT_FOUND`** for an id with no record; **`409 COPY_NOT_POSSIBLE`** for a record that cannot be copied (unique value with no copy rule, a validation refusal from the recursion); `500 COPY_FAILED` on anything else, after rollback. |
| `publish()` / `unpublish()` (`:1076`/`:1125`) | Flip the `published` flag for the requested ids. No longer renders the list, and now checks whether it worked. | `200` + `data:{ids, published}`; `400` if nothing selected; `409 PUBLISH_FAILED`. |
| `ajaxPublish()` / `ajaxUnpublish()` | **Deprecated aliases** — call `publish()`. | As `publish()`. |
| `storeOrdering()` (`:1136`) | Reads `updates` (JSON map `recordId → position`), casts to int, `$model->storeOrdering()`. | `200` + `data:{ordered: n}`; **`400 INVALID_ORDERING`** when `updates` is missing/unparseable; **`500 ORDERING_FAILED`** when the write fails (e.g. the model's table has no `ordering` column). |
| `cancel()` (`:1198`) | No model work — `setRedirect()` to the `return` URL, else the controller's list. | Redirect. |
| `save()` / `remove()` / `ajaxRemove()` (`:1453` / `:1460` / `:1467`) | **Deprecated aliases** for `store` / `delete` / `ajaxDelete`. | — |

Conventions visible across these:
- **`id` vs `ids`.** Tasks accept a single `id` or a comma-separated `ids`, normalize both to an `int[]`,
  and `intval` every element (the only real input sanitation — see the SQL caveat in
  `com_configbox_kenedo_mvc.md` §8).
- **No task renders a list any more.** `show_list=1` is dead — `delete()` dropped it (with `quickedit=1`)
  and `copy()` no longer reads it either; only a historical docblock still mentions it. A mutation reports
  what it did, and a caller that wants a fresh list asks for one in a second request — which is what the
  admin list JS already does via `refreshList`.
- **`purgeCache()`** (`:1402`, → `ConfigboxCacheHelper::purgeCache()`) runs after every mutation.
- **Every base task answers through `ConfigboxApiResponse`** now. `ConfigboxJsonResponse` remains only
  for older custom tasks going through `sendJsonResponse()`.

Router hooks (`getUrlSegments`, `getViewNameFromUrlSegments`, `getSegmentMatching`, `getSegmentParsing`,
`:1412`+) are stubs the SEF router calls — see `../platform/joomla/com_configbox_sef_urls.md`.

### How a task ends

**A task ends by returning.** It echoes into the output buffer the entry script opened
(`components/com_configbox/configbox.php`), and returning lets that script collect the output, run the
`onBeforeRender` observers and hand it to the platform. A task must not `exit`/`die`: that skips all
three, and makes the task unusable from any long-running process (the CLI commands, the MCP server).

What keeps the host template *off* a JSON response is the **raw output mode**, not an exit. A request
carrying `output_mode=view_only` is mapped to the host's raw document — on Joomla the System -
CBX plugin turns it into `format=raw` — which renders the component output and nothing else.
`KenedoPlatform::getEndpointUrl()` and the admin JS both add it.

| Helper | Use it for |
|---|---|
| **`ConfigboxApiResponse`** | **Any migrated task.** The status carries the outcome; `2xx` bodies are `{data, meta?}` and failures are RFC 9457 problem details with a stable `code`. `store`, `getRecord`, `getRecords`, `delete`, `copy`, `publish` and `storeOrdering` all use it. |
| `sendJsonResponse($data)` (`protected`) | Any JSON reply from a **custom** task. Sets the mime type, encodes, and — when the request is **not** in `view_only` mode — logs a warning, because the caller is about to receive valid JSON buried in a full host page. No base task uses it any more. |
| `finishResponse($code = 0)` (`protected`) | Only when the task has already streamed a **complete** response nothing may be appended to: a PDF or file download, a payment-provider webhook reply. Delegates to `InterfaceKenedoPlatform::endRequest()`. |

`endRequest()` is `exit()` under the hood on every platform (Joomla's `Application::close()` included)
— it is not a gentler shutdown. It exists as a **seam**: platform-agnostic code stops hardcoding a PHP
builtin, each host uses its own documented terminator, and a non-web context can override it. Reach
for raw output mode first; ending the request is the blunter instrument.

> Historical note: these tasks used to end in `die()`, from an era when a component could not trust
> the surrounding page output to be clean. Raw output mode replaced that. If you are copying an old
> customization that calls `die()` in a task, drop it.

---

## 7. Authorization (`isAuthorized`, `abortUnauthorized`)

Authorization is **coarse and by convention**, not per-task ACL (`isAuthorized()`, `:1220`):

```php
$controllerName = getControllerNameFromClass(get_class($this));
if (strpos($controllerName, 'admin') === 0) {                       // admin* controllers
    if (substr(PHP_SAPI, 0, 3) == 'cli') { return true; }           // CLI passes the gate (:1233)
    return KenedoPlatform::p()->isAuthorized($this->component.'.core.manage', NULL, 20);
}
return true;                                                        // everything else is public
```

So: **any controller whose name starts with `admin` requires the `com_configbox.core.manage` permission;
every other controller is unauthenticated.** There is no per-task or per-record check in the base class.
`abortUnauthorized()` (`:1256`) logs to the authorization log and throws a 403.

**A CLI process passes the admin gate** (`:1228-1236`, decided 2026-07-31). The CLI's trust boundary is
shell access to the site, exactly like MCP's: whoever can run `cli/joomla.php` can already read and edit
the database directly, so asking the host for a logged-in admin — which a CLI process can never have —
refused the honest front door while the back door stood open. Web requests are never a CLI SAPI, so the
host's `core.manage` check is untouched for every browser and HTTP caller on every platform.

Every mutating/reading task **calls `isAuthorized()` itself at the top** — `execute()` does not do it for
you (§3). When you add a task, follow the pattern:

```php
public function myTask() {
    $this->isAuthorized() or $this->abortUnauthorized();
    // …
}
```

> **Extending authorization** is a real reason to override `KenedoController`. If you need finer control,
> override `isAuthorized($task)` in a subclass (the `$task` param exists for exactly this, though the base
> ignores it) — e.g. gate a specific task, or check record ownership. Do it in `system_overrides/` for a
> core controller, or in your own controller subclass.

---

## 8. How the Model and View connect (the minimum)

`KenedoController` is the C; here's just enough of M and V to see the wiring. Full detail:
`com_configbox_kenedo_model.md` (the model in depth), `com_configbox_kenedo_view.md` (the view in depth),
and `com_configbox_kenedo_mvc.md` §3.

### 8.1 Controller → View

`display()`/`edit()` get a view from the factories, set `listing`, and call `wrapViewAndDisplay()` (§4).
The view then pulls its data from the **same model** the controller uses (`getDefaultModel()`), so
controller and view agree on the data source without passing it between them. The full view side —
`prepareTemplateVarsList/Form()`, the property loop, assets, the `.view-<name>` wrapper — is
`com_configbox_kenedo_view.md`.

### 8.2 Controller → Model

The write tasks delegate to the model and never touch SQL themselves:
- `store()` → `getDataFromRequest`, then the shared pipeline's `prepareForStorage` / `isInsert` /
  `validateData` / `store`, catching `KenedoValidationException` (422) and `KenedoSystemException`
  (500) (§5).
- `delete()`/`ajaxDelete()` → `$model->delete($ids)` (`KenedoModel.php:1970`), via
  `ConfigboxEntityApiHelper::deleteManyForModel()`.
- `copy()` → `$model->copy()` (recursive deep-clone; see `com_configbox_mvc_tasks.md`).
- `publish()` → `$model->publish($ids, $publish)` (`:1924`).
- `storeOrdering()` → `$model->storeOrdering($ordering)` (`:2126`).

The model's behavior is itself **property-driven** (each of the above loops the model's `KenedoProperty`
set) — the single most important concept in Kenedo, covered in `com_configbox_kenedo_mvc.md` §4.

---

## 9. Extending KenedoController

**To build a new admin CRUD screen** (most common): you don't extend the base class beyond the four
factories (§4.3). All tasks come for free; the real work is the model's `getPropertyDefinitions()` and a
migration for the column. The `adminmvcmaker` admin tool scaffolds the whole quad.

**To add behavior to a screen without a template/JS override:**
- Add a **custom task** — a `public function myTask()` (remember: authorize first, §3/§7). Reachable at
  `?option=com_configbox&controller=<name>&task=myTask`. New controllers go in
  `data/customization/controllers/` (core-first; §2.2).
- Override **`afterStore($success)`** (`:698`) — the empty hook that runs after every save. Ideal for
  side effects (notify, sync, recompute) without reimplementing `store()`.
- Override **`getListingTasks()`/`getDetailsTasks()` on the model** to change a screen's toolbar buttons
  (`com_configbox_kenedo_view.md` §7.3).

**To modify `KenedoController`'s own behavior** (the "improve the framework" case): the base class lives in
`external/kenedo/` and is shared across all platforms — treat changes as framework-level.
- The extension points are the **overridable methods**: the task methods themselves, `afterStore()`,
  `isAuthorized()`, `wrapViewAndDisplay()`, and the `getDefault*` factories. Prefer overriding a method (or
  adding a new hook like `afterStore`) over inlining logic into `execute()`.
- **`execute()` is the dispatch chokepoint.** If you want cross-cutting behavior (a global authorization
  gate, timing, an audit log) applied to *every* task, `execute()` is where to add it — but changing it
  touches every request, so change it in `system_overrides/` deliberately and test broadly.
- **Controllers are real singletons** (`self::$instances`, §2.2) — one instance per class per request, so
  instance state set by one task is visible to another within the same request. Don't rely on that across
  requests.
- **No DI, no namespaces, `KRequest`/`KLog`/`KLink`/`KenedoPlatform::p()` statics, manual JSON via
  `json_encode`/`ConfigboxJsonResponse`.** Match the surrounding style; don't introduce PSR/Joomla-MVC
  idioms (see `com_configbox_kenedo_mvc.md` §8 for the framework-wide caveats).

**Gotchas worth internalizing:**
- A task is a **public** method; helpers must be `protected`/`private` or they become request-reachable.
- **Each task authorizes itself** — `execute()` doesn't. A new mutating task without an `isAuthorized()`
  call is an open door.
- Authorization is **admin-prefix = `core.manage`, everything else public** — a non-`admin` controller is
  unauthenticated by default. And under a **CLI SAPI the admin gate passes automatically** (§7).
- Customization controllers are **core-first / new-only** — they add controllers, they don't shadow core
  ones (use `system_overrides/`).
- `apply`/`storeAndNew` are `store()`; the post-save behavior is decided client-side from `task` and
  `wasInsert` — the server sends no `redirectUrl`.

---

## See also

- `technical/com_configbox_kenedo_model.md` — the **model layer** in depth (the other side of the C→M
  delegation in §5/§8): the property-driven CRUD engine, the `store()` two-phase save, `copy()`, and the
  model customization seams.
- `technical/com_configbox_kenedo_view.md` — the **view layer** in depth (the other half of the C↔V
  handoff in §4): CRUD vs multi-purpose views, assets, curryable setters, `.view-<name>` CSS isolation.
- `technical/com_configbox_kenedo_mvc.md` — the whole MVC overview: the request lifecycle (§1), the base
  task table (§2), the property-driven model (§3–§4), customization/override precedence (§7), and
  framework caveats (§8). Read alongside this doc.
- `technical/com_configbox_mvc_tasks.md` — each base task in even more detail, with the deep dive on
  `copy()` (recursive deep-clone + two-pass ID remapping).
- `../platform/joomla/com_configbox_sef_urls.md` — the controller's SEF router hooks (`getUrlSegments`,
  `getViewNameFromUrlSegments`, `getSegmentMatching/Parsing`).
- `customization/com_configbox_overriding_controllers_and_models.md` — the integrator's how-to: adding a
  new controller vs. overriding a core one (`system_overrides/`), and the `Cbcheckout→Configbox` fallback.
- Key source: `external/kenedo/classes/KenedoController.php` (`getController` `:79`, `getControllerClass`
  `:207`, `execute` `:251`, `display`/`edit` `:312`/`:336`, `store` `:371`, `getRecord`/`getRecords`
  `:715`/`:764`, `delete`/`copy` `:836`/`:898`, `publish` `:1076`, `storeOrdering` `:1136`, `cancel`
  `:1198`, `isAuthorized` `:1220`, `wrapViewAndDisplay` `:1265`), `helpers/entityapi.php`
  (`runStorePipeline` `:649`, `deleteManyForModel` `:545`), `configbox.php` (entry dispatch),
  `external/kenedo/classes/KenedoModel.php`
  (`getDataFromRequest`/`validateData`/`store`/`delete`/`publish`/`storeOrdering`), and controller
  `admincountries` (minimal CRUD quad).
