Kenedo MVC — Base Tasks in Detail
- Version
- 3.x
- Updated
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
idorids: singleid(int) or comma-separatedids(plus the legacycid); all are normalized to anint[]and the values cast withintvalfor sanitation. - One response shape: JSON. Every data task answers through the
ConfigboxApiResponseenvelope — the status carries the outcome, refusals are problem details with a stablecode. The old render-the-list-instead branches (show_list,quickedit) are gone fromdelete()andcopy(); 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
KenedoPropertyobjects (see the MVC doc). State changes run inside a DB transaction, and mutating tasks callpurgeCache()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):
- Pre-check every id with
canDelete()— each refusal is a thrownKenedoValidationException, and refusals are absorbed across ids (:1992-2010) so the operator learns about all blocking reasons at once, not just the first. - In a transaction, for each id: loop properties calling
$property->delete($id, $table)(so a property can clean up its external/child data), thenDELETE FROM <table> WHERE <key> = id, thenafterDelete($id)+onAfterDeleteRecordobserver. - 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)
- Authorize; parse
id/ids. - Open an outer DB transaction spanning all requested records (all-or-nothing).
- For each id:
getRecord($id)— an id with no record behind it throws aKenedoNotFoundException, answered as404 RECORD_NOT_FOUNDinstead of masquerading as a system fault — then append the localized " (KText::_('COPY_NOUN'))" suffix to the record'stitle-<lang>(orname-<lang>) for every active language (the translatable virtual fields), so the clone is recognizable. $newId = model->copy($record)— a failure throws ⇒ rollback everything.- Commit,
purgeCache(), respond201with{ids, newId, redirectUrl}— note the key isids(all new ids), notnewIds, andredirectUrl(→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 — answers409 COPY_NOT_POSSIBLEwith the actual reason (and, for deep refusals, ameta.failedAttrail naming the level it was raised at); anything else is500 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:
clone $data; capture$oldId; null the primary key on the clone (so insert creates a new row).- Build the base-table row from each property's
getDataKeysForBaseTable()— asking each propertygetValueForCopy()rather than taking values verbatim, because a unique column has to become something else on a copy — andinsertObject()→$newId. - Record the mapping:
self::$copyIds[$modelName][$oldId] = $newId— a static registry (KenedoModel.php:47,:706) that therefore accumulates across the entire recursion. - Loop properties, calling
$property->copy($data, $newId, $oldId). The defaultKenedoProperty::copy()(classes/KenedoProperty.php:423) only acts forstoreExternallyproperties — 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.) - Recurse into child models:
getChildModel()+getChildModelForeignKey()define the parent→child link (e.g.ConfigboxModelProducts→ childConfigboxModelPages, FKproduct_id,models/products.php:32). It loads the children withchildModel->getRecords([fkFilter => $oldId]), and for each: clone, set the FK to the new parent$newId(leaving the child's own PK null), thenchildModel->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. - 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:
KenedoPropertyRule→copyRule()→ConfigboxRulesHelper::getRuleCopy()→ each condition'sgetCopiedConditionData($data, $copyIds)translates referencedelementId/ answer /calcIdfrom old to new (see the rule-engine doc).KenedoPropertyCalculation/KenedoPropertyCalculationOverride→copyCalculation()/copyOverrides()→ConfigboxCalculation::getFormulaCopy()→ each term'sgetCopiedTermData($data, $copyIds)(see the calculation-engine doc).KenedoPropertyChildentries→ recursescopyRulesAndCalculationsinto the child-grid records.- Finally it recurses into child models (pages → questions, and via
childentriesinto 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
$copyIdsis what lets pass 2 translate any old ID to its new counterpart anywhere in the tree — it must persist across the whole recursive copy, hencestatic. - 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), soforgetRecord(s)are documented no-ops kept for old callers; what writes do invalidate are the assignment and translation lookups (ConfigboxAssignmentsHelper::forgetForTable(),KenedoPropertyTranslatable::forget()). copyassumptions: the localizedCOPY_NOUNsuffix assumes atitle/namefield; a model with neither copies silently un-renamed. Models that own children must correctly implementgetChildModel()+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.