Skip to main content
Version: 4.0 preview

Kenedo MVC — Base Tasks in Detail

Version
4.0 preview
Updated
View markdown

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 pipelineConfigboxEntityApiHelper::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 cidConfigboxEntityApiHelper::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):

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:

  • KenedoPropertyRulecopyRule()ConfigboxRulesHelper::getRuleCopy() → each condition's getCopiedConditionData($data, $copyIds) translates referenced elementId / answer / calcId from old to new (see the rule-engine doc).
  • KenedoPropertyCalculation / KenedoPropertyCalculationOverridecopyCalculation() / 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.