KenedoController, in Detail
- Version
- 3.x
- Updated
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:
getDefaultModel()— theKenedoModelthe tasks read/write (orNULL).getDefaultView()/getDefaultViewList()/getDefaultViewForm()— theKenedoViews the display tasks render (orNULL).- 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 ispublic, then calls$this->$task(). Nothing else registers a task. - Controllers are true singletons, resolved by class name through
getController()(unlikeKenedoView, whose "singleton" is vestigial — seecom_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 itslistingflag, and callwrapViewAndDisplay()— the seam intoKenedoView(§4, andcom_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:
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)
$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
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, usesystem_overrides/. (Same rule as models and view classes; seecom_configbox_kenedo_mvc.md§7.) - Real singleton. Unlike
KenedoView::getView()(which returns a fresh instance every call), the controller registryself::$instancesis 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:
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 itprotected/private—execute()refuses non-public methods (that's whyafterStore,wrapViewAndDisplay,setRedirect,purgeCache,abortUnauthorizedareprotected). - No per-task ACL here.
execute()does not check authorization — each task callsisAuthorized()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:
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
$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):
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:
$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
FormDataover 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:
idvsids. Tasks accept a singleidor a comma-separatedids, normalize both to anint[], andintvalevery element (the only real input sanitation — see the SQL caveat incom_configbox_kenedo_mvc.md§8).- No task renders a list any more.
show_list=1is dead —delete()dropped it (withquickedit=1) andcopy()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 viarefreshList. purgeCache()(:1402, →ConfigboxCacheHelper::purgeCache()) runs after every mutation.- Every base task answers through
ConfigboxApiResponsenow.ConfigboxJsonResponseremains only for older custom tasks going throughsendJsonResponse().
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 callsdie()in a task, drop it.
7. Authorization (isAuthorized, abortUnauthorized)
Authorization is coarse and by convention, not per-task ACL (isAuthorized(), :1220):
$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:
public function myTask() {
$this->isAuthorized() or $this->abortUnauthorized();
// …
}
Extending authorization is a real reason to override
KenedoController. If you need finer control, overrideisAuthorized($task)in a subclass (the$taskparam exists for exactly this, though the base ignores it) — e.g. gate a specific task, or check record ownership. Do it insystem_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'sprepareForStorage/isInsert/validateData/store, catchingKenedoValidationException(422) andKenedoSystemException(500) (§5).delete()/ajaxDelete()→$model->delete($ids)(KenedoModel.php:1970), viaConfigboxEntityApiHelper::deleteManyForModel().copy()→$model->copy()(recursive deep-clone; seecom_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 indata/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 reimplementingstore(). - 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 thegetDefault*factories. Prefer overriding a method (or adding a new hook likeafterStore) over inlining logic intoexecute(). 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 insystem_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 viajson_encode/ConfigboxJsonResponse. Match the surrounding style; don't introduce PSR/Joomla-MVC idioms (seecom_configbox_kenedo_mvc.md§8 for the framework-wide caveats).
Gotchas worth internalizing:
- A task is a public method; helpers must be
protected/privateor they become request-reachable. - Each task authorizes itself —
execute()doesn't. A new mutating task without anisAuthorized()call is an open door. - Authorization is admin-prefix =
core.manage, everything else public — a non-admincontroller 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/storeAndNewarestore(); the post-save behavior is decided client-side fromtaskandwasInsert— the server sends noredirectUrl.
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, thestore()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 oncopy()(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 theCbcheckout→Configboxfallback.- 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 controlleradmincountries(minimal CRUD quad).