Kenedo MVC — Architecture, Base Tasks & Property-Driven Data
- Version
- 4.0 preview
- Updated
A comprehensive description of the Kenedo framework that underpins CBX: its MVC request lifecycle, the base controller tasks every controller inherits, and — the core idea — how Kenedo Properties declaratively configure a model's data (form, list, SQL, validation, storage and filtering all derive from one property-definition array).
Paths are relative to docroot/components/com_configbox/; the framework lives under
external/kenedo/.
0. What Kenedo is
Kenedo (by Rovexo) is a bespoke, platform-abstracted MVC framework. The same application code
runs on Joomla, Magento 1/2, WordPress or standalone; a KenedoPlatform singleton adapts DB,
session, user, document, routing and filesystem to the host (auto-detected at runtime). The cast:
| Class | Role |
|---|---|
KenedoController | Front-controller base; defines the base tasks (display/edit/store/delete/…) |
KenedoModel | Data layer; a property-definition-driven query builder + CRUD engine |
KenedoProperty (+ properties/*) | One class per field type; contributes SQL, validation, storage, rendering |
KenedoView (+ tmpl/*) | PHP-template views; renders lists/forms by looping a model's properties |
KenedoPlatform | Host abstraction facade (p() → the active platform impl) |
KRequest, KSession, KText, KLog, KLink, KenedoObserver | Request, session, i18n, logging, routing, events |
This document is the overview. Three of the cast members have dedicated deep-dive references:
com_configbox_kenedo_controller.md(the front controller — dispatch, tasks,store(), authorization),com_configbox_kenedo_model.md(the data layer — property-driven CRUD,store()/copy(), the child tree, the customization seams), andcom_configbox_kenedo_view.md(the view layer — CRUD vs multi-purpose views, assets, curryable setters,.view-<name>CSS isolation). Read this overview first, then descend into whichever layer you're working on.
Naming conventions drive autoloading-by-convention:
- Controller class
ConfigboxController<Name>↔ filecontrollers/<name>.php. - Model class
ConfigboxModel<Thing>↔ filemodels/<thing>.php. - View class
ConfigboxView<Name>↔ folderviews/<name>/view.html.php+tmpl/. - Property class
KenedoProperty<Type>↔ fileexternal/kenedo/properties/<type>.php.
Note the asymmetry on the admin side: model files and classes lost the admin prefix
(models/countries.php / ConfigboxModelCountries), while controllers and views keep it
(ConfigboxControllerAdmincountries, ConfigboxViewAdmincountries) — the prefix is what the admin
permission gate keys on (§2.3). A shim in KenedoModel::getModel() rewrites old
ConfigboxModelAdmin* names with a deprecation log (KenedoModel.php:215-226), and the CB4 renames
alias through the same chokepoint (Adminelements→Questions,
Adminoptions/Adminoptionassignments/Adminxrefelementoptions→Answers, :186-200), so old
customization code keeps resolving — but write new code against the new names.
Each factory looks in both the customization folder and the system folder, which is how
white-label overrides work (see §7). The direction differs by kind: properties are customization-first
(your file shadows core), while the class loaders — controllers, models, and view classes — are
core-first (the customization folder only loads a class with a name core doesn't define, i.e. a new
one). To change an existing core class you use system_overrides/, not a same-named file in
controllers//models//views/.
1. The request lifecycle
Entry file configbox.php:
initKenedo('com_configbox')boots the framework (autoload, platform singleton, error handlers, Composer + app helpers).- Reads
option/controller/view/taskfrom the request (KRequest::getKeyword). KenedoController::getControllerClass($component, $controllerName, $viewName)computes the class name:ucfirst(component-without-com_) . 'Controller' . ucfirst(name)→ e.g.ConfigboxControllerAdmincalculations(KenedoController.php:207).getController($class)loads + singletons it (system path → custom path; core-first,:79).ob_start(), then$controller->execute($task)(default taskdisplay).- The buffered output is passed through the
onBeforeRenderobserver and handed toKenedoPlatform::p()->renderOutput(). If the task set aredirectUrl, it redirects instead.
execute($task) (KenedoController.php:251) is a reflection-guarded dispatcher: it verifies
the task is an existing public method (else logs + throws "Task not found"), then calls
$this->$task(). So "a task" is literally a public method on the controller.
2. The Controller layer — base tasks
Deep dive: this section is a summary. For
KenedoControllerin full — theexecute()reflection dispatcher, thestore()flow step by step, the whole task catalog with per-task response shapes, authorization, and how to extend the controller — seecom_configbox_kenedo_controller.md.
Every controller extends KenedoController and implements four (often NULL-returning) factories:
getDefaultModel(), getDefaultView(), getDefaultViewList(), getDefaultViewForm(). The base
class then provides a complete CRUD task set out of the box.
2.1 Base task catalog
| Task (method) | What it does |
|---|---|
display() | Authorizes, gets getDefaultView(), sets $view->listing = true, wraps & renders the list. (:312) |
edit() | Authorizes, gets getDefaultViewForm(), sets listing = false, renders the edit form. (:336) |
store() | The canonical save (see §2.2). (:371) |
apply() | Calls store(); the client JS decides to stay on the (now-saved) edit form. (:680) |
storeAndNew() | Calls store(); JS then opens a blank form. (:689) |
afterStore($success) | Empty hook for subclasses to extend save behavior. (:698) |
getRecord() / getRecords() | JSON reads: one record / a page of records ({items, total, offset, limit}, limit clamped 1–200, default 50), projected into API shape via ConfigboxApiRecord. (:715, :764) |
delete() / ajaxDelete() | Delete one (id) or many (ids) via the shared deleteManyForModel() pipeline; always JSON, refusals answer 409 RECORD_IN_USE. ajaxDelete is a deprecated alias. (:836, :809) |
copy() | Deep-clones a record (and child models, rules, calculations); 201 {ids, newId, redirectUrl}, 404/409 refusals. (:898) |
publish() / unpublish() | Flip a published flag for id/ids, purge cache, answer {ids, published}. (:1076, :1125) |
ajaxPublish() / ajaxUnpublish() | Deprecated aliases for publish()/unpublish(). (:1059, :1066) |
storeOrdering() | Persist drag-reordered list positions from a JSON updates map; answers {ordered: n}. (:1136) |
cancel() | Redirect back (to return URL or the list). (:1198) |
save() / remove() / ajaxRemove() | Deprecated aliases for store / delete / ajaxDelete. (:1453) |
Router helpers (getUrlSegments, getViewNameFromUrlSegments, getSegmentMatching,
getSegmentParsing) support Joomla SEF routing.
2.2 The store() flow (the spine of all saving)
store() (KenedoController.php:371) 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:
isAuthorized() ─► seedRequestFromExistingRecord() // updates: "not sent" means "unchanged"
─► model.getDataFromRequest() // build a normalized data object from the request
─► runStorePipeline(): // shared with REST/MCP/in-process callers
prepareForStorage ─► isInsert ─► validateData ─► store ─► cache purge
─► afterStore($success) // controller hook
─► 201 (insert) / 200 (update) + {id, wasInsert, record}
A validation refusal is a thrown KenedoValidationException, answered as a 422 problem-details
response with one issue per bad field; a KenedoSystemException answers 500 with only a log identifier.
There is no redirectUrl in the response — post-save navigation is entirely the client's job (the
JS decides from task and wasInsert). The pre-pipeline step makes partial requests safe: on an update
every field the caller did not send is seeded from the stored record. Saves travel as FormData over
XHR (assets/javascript/server.js, submitForm()) — the hidden-iframe submit is gone.
2.3 Output modes & authorization
wrapViewAndDisplay($view)(:1265) selects wrapping byKenedoPlatform::p()->getOutputMode():view_only(bare view, used for AJAX),in_html_doc(component-embed), or — foradmin*views — wraps the content in theConfigboxViewAdminchrome (menu + shell).isAuthorized($task)(:1220) is intentionally coarse: any controller whose name starts withadminrequires thecom_configbox.core.managepermission; everything else returnstrue.abortUnauthorized()logs and throws a 403. (Fine-grained per-task ACL is not implemented.)- A CLI SAPI passes the admin gate automatically (
:1228-1236, decided 2026-07-31): whoever can runcli/joomla.phpcan already read and edit the database directly, so the host's logged-in-admin check — which a CLI process can never satisfy — only refused the honest front door. Web requests are never CLI; thecore.managecheck is untouched for them.
3. The Model layer — property-driven CRUD
Deep dive: this section is a summary. For
KenedoModelin full — the property assembly (getProperties()and themodel_property_customization/merge),getRecord/getRecordsand the SQL-building style, thestore()two-phase save, the recursivecopy()+ child tree, delete-safety, and the four model customization seams — seecom_configbox_kenedo_model.md.
A KenedoModel subclass usually declares only three things explicitly:
getTableName(), getTableKey(), and getPropertyDefinitions() (an array of field
descriptors). Everything else — reading, writing, validating, listing, filtering, copying — is
assembled generically by looping over the properties.
3.1 From definitions to property objects
getProperties() (KenedoModel.php:1243):
getPropertyDefinitions()(the model's own array) +getCustomPropertyDefinitions()(merged-in overrides fromdata/customization/model_property_customization/<model>.php,:1169),array_merged,uasorted bypositionForm,- not filtered by platform (
:1584) — every property exists on every host, and theplatformskey ('platforms' => array('joomla', 'wordpress'); absent means everywhere) says where it applies, which hides it in the form and drops itsrequired(see §4.1), - each turned into a
KenedoProperty<Type>object viagetPropertyObject()(:1364) — which resolves the property class file from the customization folder, thenexternal/kenedo/properties.
Results are memoized (memoGetProperties). Type generation reads definitions rather than this method
(getPropertiesForAllPlatforms(), :1617), so the committed artifacts describe every platform at once.
3.2 Reading: getRecord() / getRecords()
getRecord($id, $languageTag) (:1616) builds the SELECT by asking every property for its
contributions:
foreach ($props as $prop) {
$selects = array_merge($selects, $prop->getSelectsForGetRecord());
$joins = array_merge($joins, $prop->getJoinsForGetRecord());
}
// SELECT <selects> FROM <table> AS <ModelName> <joins> WHERE <key> = <id>
$record = $db->loadObject();
foreach ($props as $prop) { $prop->appendDataForGetRecord($record); } // post-load enrichment
getRecords() (:1694) does the same for lists, additionally folding in filters (each
property's getWheres()), pagination, sort specs, language, and an optional count-only mode.
Records are not cached — record memoization was removed 2026-07-26, so every call is a query
(the forgetRecord/forgetRecords methods survive as documented no-ops; see
com_configbox_kenedo_model.md §4.1).
3.3 Writing: store()
store($data) (:476) runs in a DB transaction:
- Build the base-table row by asking each property
getDataKeysForBaseTable($data)(properties stored elsewhere return[]), theninsertObject()(insert or update on the primary key). - Loop properties and call
$property->store($data)— each persists its own side data (external tables, translations, child rows). Afalserolls back. - Invalidate what the write can stale —
ConfigboxAssignmentsHelper::forgetForTable()+KenedoPropertyTranslatable::forget()(:568-575; record data itself is never cached) — thenafterStore(id, wasInsert)hook, commit, fireonAfterStoreRecordobserver.
getDataFromRequest(), prepareForStorage(), validateData() (:329–:429) likewise just loop
the properties, delegating to each property's getDataFromRequest(), prepareForStorage(), and
check(). initData() (:1476) builds an empty record honoring default and prefill_<name>
request values.
3.4 Other model operations
delete()/canDelete() (:1970/:2093), publish() (:1924), storeOrdering() (:2126),
copy() (:649, recursive deep-clone of base + child models + rules/calcs), getRecordUsage()
(reverse-reference reporting). All are generic and driven by the same property set.
4. Kenedo Properties — how data is configured (the core idea)
A model's behavior is declared, not coded: you write a property-definition array; from it the framework generates the edit form, the list grid, the SELECT/JOIN SQL, validation, storage, search and filters. This is the single most important concept in Kenedo.
4.1 A property definition is an associative array
Example (from models/calculations.php):
$propDefs['name'] = array(
'name' => 'name', // DB column + request key
'label' => KText::_('Name'),
'type' => 'string', // ⇒ KenedoPropertyString
'required' => 1,
'makeEditLink' => true, // list cell links to the edit form
'addSearchBox' => true, // list gets a search box on this column
'addDropdownFilter'=> true, // list gets a dropdown filter
'canSortBy' => true,
'positionList' => 10, // column order in the list
'positionForm' => 200, // field order in the form
);
$propDefs['product_id'] = array(
'name' => 'product_id',
'type' => 'join', // a relation
'modelClass' => 'ConfigboxModelProducts',
'modelMethod' => 'getFilterSelectData',
'propNameKey' => 'id', // FK column
'propNameDisplay'=> 'title', // shown label
'parent' => 1, // parent in the entity tree (filters children)
'lockedAfterStore'=> true,
'required' => 1,
'positionForm' => 300,
);
$propDefs['type'] = array(
'name' => 'type', 'type' => 'radio', 'default' => 'matrix',
'choices' => array('matrix'=>..., 'formula'=>..., 'code'=>...),
);
Common definition keys: name, type, label, tooltip, required, default, positionForm,
positionList, canSortBy, addSearchBox, addDropdownFilter, makeEditLink, listCellWidth,
choices (radio/dropdown), propNameKey/propNameDisplay/modelClass/modelMethod/parent
(join), options (space-separated flags → optionTags, e.g. ALLOW_RAW, ALLOW_HTML),
platforms (an array naming the platforms the property exists on — absent means all;
getProperties() drops excluded ones at runtime, KenedoModel.php:1263-1267, and store() fills
their declared default on INSERT so the column stays valid everywhere — see
com_configbox_property_definition_settings.md §11), and the
external-storage trio storeExternally + foreignTableName/foreignTableAlias/foreignTableKey.
modernizeOldPropertySettings() (KenedoProperty.php:110) silently upgrades legacy key names
(e.g. listing→positionList, filter→addDropdownFilter).
4.2 The property lifecycle hooks (what the model loops call)
Each KenedoProperty subclass may implement these; the model calls them at the right phase:
| Phase | Hook | Purpose |
|---|---|---|
| Read request | getDataFromRequest(&$data) | pull this field's value(s) from the request (honors ALLOW_RAW/ALLOW_HTML) |
| Pre-store | prepareForStorage(&$data) | normalize/auto-fill |
| Validate | check($data) | required/format checks; throws KenedoValidationException::forProperty($this, $msg) to refuse |
| Store (base) | getDataKeysForBaseTable($data) | which keys go in the model's own table ([] if stored elsewhere) |
| Store (side) | store(&$data) | persist external/translation/child data, then unset it from $data |
| Read (list/record) | getSelectsForGetRecord(), getJoinsForGetRecord() | SQL fragments this field contributes |
| Read (post) | appendDataForGetRecord(&$record) | enrich the loaded record (display values, derived data) |
| Filter | getWheres($filters), getFilterInput() | list filtering / search |
| Render (form) | getBodyAdmin() / getPropertyFormOutput() | the form widget HTML (via properties/tmpl/<type>.php) |
| Render (list) | getCellContentInListingTable(), getHeaderCellContentInListingTable() | grid cell + sortable header |
| Copy/delete | copy(), canDelete(), delete() | participate in clone/delete |
So adding a property automatically wires it into all of these — the payoff of the design.
4.3 External storage (storeExternally)
A property whose data lives in a side table sets storeExternally + the foreignTable* keys.
store() then does an INSERT … ON DUPLICATE KEY UPDATE into that table
(KenedoProperty.php:366) and getJoinsForGetRecord() adds the matching LEFT JOIN
(:1151). This is how 1:1 satellite data attaches without bloating the base table, and it is the
main tool for adding fields to a stock entity without altering a stock table.
Full treatment — which types support it, what happens on delete and copy, and the constraints the side table must satisfy — is
com_configbox_property_types.md§3. Note thattranslatableis not an example of this: it is aderivedproperty with its own EAV storage, andstoreExternallydoes not apply to it.
4.4 The property type catalog (external/kenedo/properties/)
~30 types, each KenedoProperty<Type> + a tmpl/<type>.php render partial. What follows is the
one-paragraph map; the reference is
com_configbox_property_types.md, which carries the shared
settings, the three storage kinds, a "which type do I want" table, and one article per type under
property-types/.
- Scalars/UI:
id,string,radio,dropdown,multiselect,boolean,published,datetime,json,ordering. translatable— i18n: stores/reads text in the polymorphic#__configbox_strings(type,key,language_tag,text) table via per-language joins; the language comes fromgetRecord($id, $languageTag).join— relations. Recursively buildsLEFT JOINs up parent chains and exposes related columns under thejoinedby_<prop>_to_<model>_…alias convention;parent/propNameKey/propNameDisplayconfigure the FK, the displayed label and the entity tree. This is Kenedo's "ORM relations".file,image— uploads (with resizing/derivatives for image).- Domain-specific composites:
calculation&calculationoverride(assign/override a pricing calculation),rule(the conditional-logic JSON; see the rule-engine doc),childentries(one-to-many child grids),taxclassrates,groupprice,paymentmethodparams,countryselect/stateselect/countyselect, and theshapediver*family. - Form layout:
groupstart/groupendwrap fields into visual fieldsets, andnoteplaces static text. These are storage kindlayout— they never reach a record at all.
5. The View layer (closing the MVC loop)
Deep dive: this section is a summary. For
KenedoViewin full — the CRUD-view vs multi-purpose-view conventions, how a view controls the CSS/JS it loads, class-property and curryable-setter conventions, and the.view-<viewname>CSS-isolation rule — seecom_configbox_kenedo_view.md.
KenedoView renders PHP templates and is itself property-aware for lists/forms.
getView($class)resolves the view file custom→system (KenedoView.php:181).display()→getHtml()→renderView().- Template resolution (
renderView,:459) tries, in order: the Joomla template override path →data/customization/templates/<view>/<tmpl>.php→ the view's owntmpl/<tmpl>.php. The first match isinclude()d with$thisbound to the view. (<tmpl>defaults todefault, or thelayoutrequest param.) prepareTemplateVarsForm()(:375) loads the record (getRecord($id)orinitData()) and$this->properties = $model->getProperties(). The sharedtmpl/default-form.phpthen iterates those properties and emits each one's widget:So the form is literally a render of the model's property list — same source of truth as the SQL and validation. Lists useforeach ($this->properties as $property) {$property->setData($this->record);// <div class="kenedo-property ..."> label + $property->getBodyAdmin() </div>}default-list.php/default-table.phpanalogously.getViewAttributes()(:906) emits the wrapper<div>withdata-init-calls-once/eachanddata-stylesheets, which the AMD loader (main.js) reads to lazily wire up JS modules. Views declare their JS viagetJsInitCallsEach()/Once().
6. End-to-end: "add a field" in practice
To add a new column to an admin entity you typically only add one entry to the model's
getPropertyDefinitions(). From that single declaration you automatically get: the form widget, the
list column (sortable/searchable/filterable per its flags), request parsing, validation, the
SELECT/JOIN SQL, storage (base or external), and participation in copy/delete. No controller, view,
or query code is touched. That is the whole point of the property model.
The matching DB column must be created through the migration system — never by hand. A property
definition only maps an existing column; the column itself is added by a versioned upgrade script
(helpers/updates/<version>.php) so that every install/upgrade gets the schema change
deterministically. Concretely: add the property def to the model and add an idempotent
ALTER TABLE … ADD COLUMN … script (guarded with ConfigboxUpdateHelper::tableFieldExists()) under
helpers/updates/. See com_configbox_migrations.md for how the upgrade/migration system works.
7. Extending Kenedo in the customization layer
Everything resolves against getDirCustomization() = …/com_configbox/data/customization (gitignored,
upgrade-safe). Whether your file shadows core or only loads as a new addition depends on the kind:
the class loaders (controllers, models, views) are core-first (new classes only), while templates,
properties, rule conditions and PSP connectors are customization-first (they shadow core). To change an
existing core class, use system_overrides/.
- Controllers —
data/customization/controllers/<name>.php(a new controller; core-first, does not shadow a core controller — change an existing one viasystem_overrides/). - Models —
data/customization/models/<name>.php(a new model; core-first, same as controllers). - View templates —
data/customization/templates/<view>/<tmpl>.php(or a Joomla template override). - Add/override property defs on a model —
data/customization/model_property_customization/<model>.phpwith a functioncustomPropertyDefinitions<Model>()returning extra defs. - Custom property types —
data/customization/properties/<type>.php(classKenedoProperty<Type>). - Custom rule conditions / calc terms / functions / question types — their respective
data/customization/<…>folders (see the rule- and calculation-engine docs). - Event observers — uploadable via the admin Connectors UI.
The admin MVC Maker (adminmvcmaker) scaffolds a controller + model + form/list views for a new
custom entity from templates, so a developer can stand up a fully-CRUD-able admin screen by writing
mostly property definitions.
8. Notable characteristics & caveats
Strengths
- A genuinely DRY, metadata-driven model: one property array generates form, list, SQL, validation, storage and filtering — fast to build admin entities, consistent UX.
- Clean platform abstraction; a coherent customization/override system that survives upgrades.
Caveats (for any future refactor)
- No DI / pervasive statics & singletons (
getController/getModel/getViewregistries,KenedoPlatform::p(),KText,KSession) — almost everything is globally reachable and hard to unit-test in isolation. - No namespaces / PSR-4; loading relies on naming conventions + a hand-maintained autoload map.
- Manual string-built SQL across the existing code (an opt-in prepared statement layer,
KenedoDatabase::setPreparedQuery(), exists since 2026-08 for new queries); IDs areintval-cast and most values escaped, but identifiers/sort columns derived from metadata are interpolated more loosely. The database layer —setQuery/getQuoted/getEscapeddiscipline,setPreparedQuery, the savepoint-emulated transactions — has its own reference:com_configbox_kenedo_database.md. - God classes (
KenedoModel~2.2k LOC,KenedoProperty~1.5k,KenedoController~1.5k) and long-lived legacy-compat branches (MERGELEGACY,modernizeOldPropertySettings,KLog::logLegacyCall). - Coarse authorization (admin =
core.manage, no per-task ACL) — and under a CLI SAPI the admin gate passes automatically (§2.3): shell access already implies DB access, so the CLI is trusted the way MCP is. Anything that must not be reachable from a site's shell does not belong behindisAuthorized()alone.
The realistic modernization anchors are exactly the two best ideas here: the platform interface
(swap a PDO/query-builder behind InterfaceKenedoDatabase) and the property-definition metadata
(keep the declarative model, give properties typed contracts and a DI-resolved registry).