# Kenedo MVC — Base Tasks in Detail

> A deep look at every base controller task Kenedo provides (display, edit, store/apply, delete, publish, storeOrdering, cancel, copy, …) — what each one does,…

Source: CBX documentation, version 4.0 preview (unreleased). Canonical page: https://docs.configbox.at/docs/4.0-preview/technical/mvc_tasks. Last updated 2026-08-03.

---
A deep look at every base controller **task** Kenedo provides (`display`, `edit`, `store`/`apply`,
`delete`, `publish`, `storeOrdering`, `cancel`, `copy`, …) — what each one does, the model methods it
drives, and how the data flows. **`copy()` is covered in depth** because it is the most intricate
(a recursive, two-pass deep-clone with ID remapping).

Companion to `com_configbox_kenedo_mvc.md` (§2 lists the tasks; this doc expands them). Paths are
relative to `docroot/components/com_configbox/external/kenedo/`.

---

## 0. What "a task" is, and the patterns they share

A task is just a **public method on a controller**. `execute($task)`
(`classes/KenedoController.php:251`) reflection-checks that the method exists and is public, then
calls `$this->$task()`. The default task is `display`.

Almost every base task shares the same scaffolding:
- **Authorize first:** `$this->isAuthorized() or $this->abortUnauthorized();` (admin controllers ⇒
  `com_configbox.core.manage`; a CLI SAPI passes the admin gate automatically).
- **Accept `id` or `ids`:** single `id` (int) or comma-separated `ids` (plus the legacy `cid`); all
  are normalized to an `int[]` and the values cast with `intval` for sanitation.
- **One response shape: JSON.** Every data task answers through the `ConfigboxApiResponse` envelope —
  the status carries the outcome, refusals are problem details with a stable `code`. The old
  render-the-list-instead branches (`show_list`, `quickedit`) are gone from `delete()` *and*
  `copy()`; only a historical docblock still mentions them.
- **Delegate to the model, which loops its properties:** the controller is thin; the model carries
  out the work by iterating its `KenedoProperty` objects (see the MVC doc). State changes run inside
  a **DB transaction**, and mutating tasks call `purgeCache()` afterward.

The full task set: `display`, `edit`, `store`, `apply`, `storeAndNew`, `afterStore` (hook),
`getRecord`, `getRecords` (the JSON read tasks — one record / a clamped page of records, default
limit 50, hard max 200), `delete`, `ajaxDelete`, `copy`, `publish`/`unpublish`,
`ajaxPublish`/`ajaxUnpublish`, `storeOrdering`, `cancel`, plus deprecated aliases `save`→store,
`remove`→delete, `ajaxRemove`→ajaxDelete, and the SEF router helpers (`getUrlSegments`,
`getViewNameFromUrlSegments`, `getSegmentMatching`, `getSegmentParsing`).

---

## 1. `display()` — render the list

`KenedoController.php:312`. Authorizes, gets `getDefaultView()`, sets `$view->listing = true`, and
calls `wrapViewAndDisplay($view)`. The view's `prepareTemplateVarsList()` pulls records via
`model->getRecords($filters, $pagination, $sortSpecs, $lang)` (each property contributes its
SELECT/JOIN/WHERE), and the `default-list.php` template renders the grid (one column per property
that opts into the listing). Output wrapping depends on the output mode (`view_only` / `in_html_doc`
/ admin chrome).

## 2. `edit()` — render the form

`KenedoController.php:336`. Authorizes, gets `getDefaultViewForm()`, sets `$view->listing = false`.
`prepareTemplateVarsForm()` loads the record (`getRecord($id)`) or an empty one (`initData()`), and
`default-form.php` renders each property's widget (`$property->getBodyAdmin()`). The record +
property definitions are also emitted as JSON `data-*` attributes for the form's JS.

## 3. `store()` / `apply()` / `storeAndNew()` — save

`KenedoController.php:371`. The controller reads the request, then runs the **shared store
pipeline** — `ConfigboxEntityApiHelper::runStorePipeline()` (`helpers/entityapi.php:649`), the same
call the REST controller and the MCP tools make:
```
                                        into the flat title_en_GB keys the properties read
seedRequestFromExistingRecord()       → updates only: fields not sent are seeded from the
                                        stored record, so "not sent" means "unchanged"
getDataFromRequest()  → build a normalized $data object (each property reads the request)
runStorePipeline():                                             [shared with REST/MCP]
    prepareForStorage()  → auto-fill/normalize (e.g. SEF slugs)  [property loop]
    isInsert()           → empty PK ⇒ insert (asked here, the only right window)
    validateData()       → required/format checks; THROWS        [property loop]
    store($data)         → transactional persist                 [see below]
    cache purge
afterStore($success)  → controller hook
→ 201 (insert) / 200 (update) + { id, wasInsert, record }
```
`model->store()` (`classes/KenedoModel.php:476`) opens a transaction, builds the **base-table row**
from every property's `getDataKeysForBaseTable()`, `insertObject()`s it (insert or PK-update), then
loops properties calling `$property->store($data)` (each persists its own external/translation/child
data), runs `afterStore()`, commits, and fires the `onAfterStoreRecord` observer. Failures throw and
roll the whole thing back.

A validation refusal is a **thrown `KenedoValidationException`** answered as a **422** problem-details
response, one issue per bad field, without persisting. There is **no `redirectUrl`** in the response:
post-save navigation is entirely the client's, decided from `task` and `wasInsert`. `apply()` and
`storeAndNew()` simply call `store()` — the difference is client-side. The form itself posts as
**`FormData` over XHR** (`assets/javascript/server.js`, `submitForm()`); the hidden-iframe submit is
gone.

## 4. `cancel()`

`KenedoController.php:1198`. No data change — just sets a redirect back to the `return` URL (base64
encoded) or the controller's list.

## 5. `delete()` / `ajaxDelete()` (+ `remove`/`ajaxRemove`)

Controller `delete()` (`KenedoController.php:836`): authorize → parse `id`/`ids`/legacy `cid` →
`ConfigboxEntityApiHelper::deleteManyForModel($model, $ids)` — the **shared pipeline** (delete +
cache forgetting live there, identical for REST and MCP) → respond with the `ConfigboxApiResponse`
envelope, always: `200 {ids, deleted}`, `400` on empty selection, and **`409 RECORD_IN_USE`** when a
record is still referenced (the model's own "linked with these records" explanation travels — the
state forbids it, not the request, which is precisely what Conflict means). `ajaxDelete()` is a
deprecated alias that calls it: it existed only to force the JSON branch, and the HTML (`show_list`)
and redirect (`quickedit`) branches are gone.

`model->delete($ids)` (`KenedoModel.php:1970`):
1. **Pre-check every id with `canDelete()`** — each refusal is a thrown `KenedoValidationException`,
   and refusals are absorbed across ids (`:1992-2010`) so the operator learns about *all* blocking
   reasons at once, not just the first.
2. In a transaction, for each id: loop properties calling `$property->delete($id, $table)` (so a
   property can clean up its external/child data), then `DELETE FROM <table> WHERE <key> = id`, then
   `afterDelete($id)` + `onAfterDeleteRecord` observer.
3. On a MySQL **FK error 1451** it surfaces a friendly "linked with other records" message and rolls
   back.

`canDelete($id)` (`:2093`) asks each property's `canDelete()` and checks `getRecordUsage($id)`
(reverse references discovered through join properties) — e.g. you can't delete a product that still
has pages. Blocking reasons become the 409's user-facing issues.

## 6. `publish()` / `unpublish()` (+ ajax variants)

Controller `publish($publish=true)` (`KenedoController.php:1076`): parse `id`/`ids`,
`model->publish($ids, $publish)`, purge cache, answer `200 {ids, published}` (`400` on empty
selection, `409 PUBLISH_FAILED` on a refusal). It used to end by re-rendering the whole list and
never checked whether the publish had worked; a mutation now reports what it did, and a caller that
wants a fresh list asks for one. `unpublish()` calls it with `false`; `ajaxPublish`/`ajaxUnpublish`
are deprecated aliases.

`model->publish()` (`KenedoModel.php:1924`) finds the model's property **of type `published`** to
learn the column name, then `UPDATE … SET <publishedCol> = 0|1 WHERE key IN (ids)`. (So a model
without a `published` property throws — publishing is property-driven like everything else.)

## 7. `storeOrdering()`

Controller (`KenedoController.php:1136`) takes a JSON `updates` map (recordId → position), answers
`400 INVALID_ORDERING` when it is missing or unparseable (it used to reach `foreach()` and warn per
call), and calls `model->storeOrdering()` (`KenedoModel.php:2126`), which runs an
`UPDATE … SET ordering = <n>` per id. Success answers `200 {ordered: n}`; a failed write — e.g. an
unorderable model with no `ordering` column — answers **`500 ORDERING_FAILED`** with a log
identifier instead of the host's error page. Used by drag-to-reorder list UIs.

## 8. `copy()` — recursive, two-pass deep-clone (in depth)

Copying a record is the most involved task because CBX entities form deep trees
(Product → Pages → Questions → Answers) with **internal cross-references** (rules and
calculations that point at *other* questions/answers/calculations by ID). A naive row copy would
duplicate the tree but leave every rule/calc pointing at the **original** records. Kenedo solves this
with a **two-pass** algorithm and a global **old→new ID map**.

### 8.1 Controller `copy()` (`KenedoController.php:898`)
1. Authorize; parse `id`/`ids`.
2. Open an **outer DB transaction** spanning all requested records (all-or-nothing).
3. For each id: `getRecord($id)` — an id with no record behind it throws a
   `KenedoNotFoundException`, answered as **`404 RECORD_NOT_FOUND`** instead of masquerading as a
   system fault — then append the **localized** " (`KText::_('COPY_NOUN')`)" suffix to the record's
   `title-<lang>` (or `name-<lang>`) for **every active language** (the translatable virtual
   fields), so the clone is recognizable.
4. `$newId = model->copy($record)` — a failure **throws** ⇒ rollback everything.
5. Commit, `purgeCache()`, respond **`201`** with `{ids, newId, redirectUrl}` — note the key is
   `ids` (all new ids), not `newIds`, and `redirectUrl` (→ `edit(newId)`) is **advisory**: the
   server does not redirect. A record that simply cannot be copied — a unique value with no rule for
   making the copy distinct, or a validation refusal from anywhere in the recursion — answers
   **`409 COPY_NOT_POSSIBLE`** with the actual reason (and, for deep refusals, a `meta.failedAt`
   trail naming the level it was raised at); anything else is `500 COPY_FAILED`, after rollback.

### 8.2 Model `copy()` — pass 1: clone the tree (`KenedoModel.php:649`)
Runs in its **own transaction** (nested transactions are emulated with savepoints), and recurses:
1. `clone $data`; capture `$oldId`; **null the primary key** on the clone (so insert creates a new row).
2. Build the base-table row from each property's `getDataKeysForBaseTable()` — asking each property
   `getValueForCopy()` rather than taking values verbatim, because a unique column has to become
   something else on a copy — and `insertObject()` → `$newId`.
3. **Record the mapping:** `self::$copyIds[$modelName][$oldId] = $newId` — a **static** registry
   (`KenedoModel.php:47`, `:706`) that therefore accumulates across the *entire* recursion.
4. Loop properties, calling `$property->copy($data, $newId, $oldId)`. The default
   `KenedoProperty::copy()` (`classes/KenedoProperty.php:423`) only acts for **`storeExternally`**
   properties — it re-inserts the side-table row keyed to `$newId`
   (`INSERT … ON DUPLICATE KEY UPDATE`, with NULL/column-default handling). This is how **translations
   and other satellite data** are duplicated. (Rules/calcs are intentionally *not* remapped here — see
   pass 2.)
5. **Recurse into child models:** `getChildModel()` + `getChildModelForeignKey()` define the
   parent→child link (e.g. `ConfigboxModelProducts` → child `ConfigboxModelPages`, FK
   `product_id`, `models/products.php:32`). It loads the children with
   `childModel->getRecords([fkFilter => $oldId])`, and for each: clone, **set the FK to the new parent
   `$newId`** (leaving the child's own PK null), then `childModel->copy($childCopy)` — recursing the
   whole algorithm down the tree. A refusal anywhere throws with its identity intact; each level
   rolls back its own savepoint and rethrows, so what the controller catches is what was originally
   raised (with a context trail added per level), not the Nth flattening of it.
6. Commit, fire `onAfterCopyRecord`, return `$newId`.

After pass 1, the full subtree is duplicated and `self::$copyIds` holds the complete old→new ID map
for every model/record touched (e.g. `['questions' => [9=>132, 10=>133, …], 'answers' => […], …]`).

### 8.3 Pass 2: remap the cross-references (`copyRulesAndCalculations`, `KenedoModel.php:876`)
Pass 1 cannot fix a rule on question A that references question B, because B's new ID may not exist
yet while A is being copied. So a **second pass runs after the whole tree is cloned**, when the ID map
is complete. The products model's `copy()` shows the orchestration (`models/products.php:1278`):
```php
function copy($data) {
    $id = parent::copy($data);              // pass 1: deep-clone tree, fill self::$copyIds
    $copyIds = self::$copyIds;
    $this->copyRulesAndCalculations($id, $copyIds);  // pass 2: rewrite ID references
    return $id;
}
```
`copyRulesAndCalculations($recordId, $copyIds)` walks the properties of the new record and, for the
reference-bearing property types, rewrites their stored JSON using the map:
- **`KenedoPropertyRule`** → `copyRule()` → `ConfigboxRulesHelper::getRuleCopy()` → each condition's
  `getCopiedConditionData($data, $copyIds)` translates referenced `elementId` / answer / `calcId` from
  old to new (see the rule-engine doc).
- **`KenedoPropertyCalculation`** / **`KenedoPropertyCalculationOverride`** → `copyCalculation()` /
  `copyOverrides()` → `ConfigboxCalculation::getFormulaCopy()` → each term's
  `getCopiedTermData($data, $copyIds)` (see the calculation-engine doc).
- **`KenedoPropertyChildentries`** → recurses `copyRulesAndCalculations` into the child-grid records.
- Finally it recurses into child models (pages → questions, and via `childentries` into answers) so
  every level's rules and calcs are remapped.

The result: a fully independent copy of the product whose internal rules and calculations point at the
**copied** questions/answers/calculations, not the originals.

### 8.4 Why it's structured this way (summary)
- **Static `$copyIds`** is what lets pass 2 translate any old ID to its new counterpart anywhere in
  the tree — it must persist across the whole recursive copy, hence `static`.
- **Two passes** decouple "create all new rows" from "rewrite references between them," which is the
  only correct order when references can point forward/sideways in the tree.
- **Nested transactions** (controller-outer + per-model) make the whole multi-record, multi-level copy
  atomic; any failure anywhere rolls everything back.
- It is, by the author's own framing, a deliberately elaborate ~200-line method — the price of
  supporting arbitrarily deep, self-referential configurator products.

---

## 9. Router-helper "tasks"

`getUrlSegments()`, `getViewNameFromUrlSegments()`, `getSegmentMatching()`, `getSegmentParsing()`
(`KenedoController.php:1412`+) aren't user tasks; they're called by `router.php` to build/parse SEF
URLs for the controller's views.

---

## 10. Cross-cutting notes & gotchas

- **Everything is property-driven:** store/copy/delete/publish all loop the model's properties and
  delegate — add a property and it automatically participates in all of them.
- **Authorization is coarse:** admin = `core.manage`; there is no per-task ACL — and a CLI SAPI
  passes the admin gate automatically (shell access already implies DB access).
- **Transactions & cache:** mutating tasks wrap work in transactions and call `purgeCache()`.
  Record data is never memoized (every read is a query since 2026-07-26), so `forgetRecord(s)` are
  documented no-ops kept for old callers; what writes *do* invalidate are the assignment and
  translation lookups (`ConfigboxAssignmentsHelper::forgetForTable()`,
  `KenedoPropertyTranslatable::forget()`).
- **`copy` assumptions:** the localized `COPY_NOUN` suffix assumes a `title`/`name` field; a model
  with neither copies silently un-renamed. Models that own children **must** correctly implement
  `getChildModel()` + `getChildModelForeignKey()` or the deep copy throws.
- **No down-migration analog:** copy/delete are immediate; deletes are guarded by `canDelete()` +
  FK 1451 handling rather than soft-delete.
