Skip to main content
Version: 3.x

Kenedo MVC — Architecture, Base Tasks & Property-Driven Data

Version
3.x
Updated
View markdown

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:

ClassRole
KenedoControllerFront-controller base; defines the base tasks (display/edit/store/delete/…)
KenedoModelData 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
KenedoPlatformHost abstraction facade (p() → the active platform impl)
KRequest, KSession, KText, KLog, KLink, KenedoObserverRequest, 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), and com_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> ↔ file controllers/<name>.php.
  • Model class ConfigboxModel<Thing> ↔ file models/<thing>.php.
  • View class ConfigboxView<Name> ↔ folder views/<name>/view.html.php + tmpl/.
  • Property class KenedoProperty<Type> ↔ file external/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 (AdminelementsQuestions, Adminoptions/Adminoptionassignments/AdminxrefelementoptionsAnswers, :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:

  1. initKenedo('com_configbox') boots the framework (autoload, platform singleton, error handlers, Composer + app helpers).
  2. Reads option / controller / view / task from the request (KRequest::getKeyword).
  3. KenedoController::getControllerClass($component, $controllerName, $viewName) computes the class name: ucfirst(component-without-com_) . 'Controller' . ucfirst(name) → e.g. ConfigboxControllerAdmincalculations (KenedoController.php:207).
  4. getController($class) loads + singletons it (system path → custom path; core-first, :79).
  5. ob_start(), then $controller->execute($task) (default task display).
  6. The buffered output is passed through the onBeforeRender observer and handed to KenedoPlatform::p()->renderOutput(). If the task set a redirectUrl, 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 KenedoController in full — the execute() reflection dispatcher, the store() flow step by step, the whole task catalog with per-task response shapes, authorization, and how to extend the controller — see com_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 pipelineConfigboxEntityApiHelper::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 by KenedoPlatform::p()->getOutputMode(): view_only (bare view, used for AJAX), in_html_doc (component-embed), or — for admin* views — wraps the content in the ConfigboxViewAdmin chrome (menu + shell).
  • isAuthorized($task) (:1220) is intentionally coarse: any controller whose name starts with admin requires the com_configbox.core.manage permission; everything else returns true. 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 run cli/joomla.php can 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; the core.manage check is untouched for them.

3. The Model layer — property-driven CRUD

Deep dive: this section is a summary. For KenedoModel in full — the property assembly (getProperties() and the model_property_customization/ merge), getRecord/getRecords and the SQL-building style, the store() two-phase save, the recursive copy() + child tree, delete-safety, and the four model customization seams — see com_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):

  1. getPropertyDefinitions() (the model's own array) +
  2. getCustomPropertyDefinitions() (merged-in overrides from data/customization/model_property_customization/<model>.php, :1169),
  3. array_merged, uasorted by positionForm,
  4. not filtered by platform (:1584) — every property exists on every host, and the platforms key ('platforms' => array('joomla', 'wordpress'); absent means everywhere) says where it applies, which hides it in the form and drops its required (see §4.1),
  5. each turned into a KenedoProperty<Type> object via getPropertyObject() (:1364) — which resolves the property class file from the customization folder, then external/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:

  1. Build the base-table row by asking each property getDataKeysForBaseTable($data) (properties stored elsewhere return []), then insertObject() (insert or update on the primary key).
  2. Loop properties and call $property->store($data) — each persists its own side data (external tables, translations, child rows). A false rolls back.
  3. Invalidate what the write can stale — ConfigboxAssignmentsHelper::forgetForTable() + KenedoPropertyTranslatable::forget() (:568-575; record data itself is never cached) — then afterStore(id, wasInsert) hook, commit, fire onAfterStoreRecord observer.

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. listingpositionList, filteraddDropdownFilter).

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:

PhaseHookPurpose
Read requestgetDataFromRequest(&$data)pull this field's value(s) from the request (honors ALLOW_RAW/ALLOW_HTML)
Pre-storeprepareForStorage(&$data)normalize/auto-fill
Validatecheck($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)
FiltergetWheres($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/deletecopy(), 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 that translatable is not an example of this: it is a derived property with its own EAV storage, and storeExternally does 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 from getRecord($id, $languageTag).
  • join — relations. Recursively builds LEFT JOINs up parent chains and exposes related columns under the joinedby_<prop>_to_<model>_… alias convention; parent/propNameKey/ propNameDisplay configure 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 the shapediver* family.
  • Form layout: groupstart / groupend wrap fields into visual fieldsets, and note places static text. These are storage kind layout — they never reach a record at all.

5. The View layer (closing the MVC loop)

Deep dive: this section is a summary. For KenedoView in 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 — see com_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 own tmpl/<tmpl>.php. The first match is include()d with $this bound to the view. (<tmpl> defaults to default, or the layout request param.)
  • prepareTemplateVarsForm() (:375) loads the record (getRecord($id) or initData()) and $this->properties = $model->getProperties(). The shared tmpl/default-form.php then iterates those properties and emits each one's widget:
    foreach ($this->properties as $property) {
    $property->setData($this->record);
    // <div class="kenedo-property ..."> label + $property->getBodyAdmin() </div>
    }
    So the form is literally a render of the model's property list — same source of truth as the SQL and validation. Lists use default-list.php / default-table.php analogously.
  • getViewAttributes() (:906) emits the wrapper <div> with data-init-calls-once/each and data-stylesheets, which the AMD loader (main.js) reads to lazily wire up JS modules. Views declare their JS via getJsInitCallsEach()/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/.

  • Controllersdata/customization/controllers/<name>.php (a new controller; core-first, does not shadow a core controller — change an existing one via system_overrides/).
  • Modelsdata/customization/models/<name>.php (a new model; core-first, same as controllers).
  • View templatesdata/customization/templates/<view>/<tmpl>.php (or a Joomla template override).
  • Add/override property defs on a modeldata/customization/model_property_customization/<model>.php with a function customPropertyDefinitions<Model>() returning extra defs.
  • Custom property typesdata/customization/properties/<type>.php (class KenedoProperty<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/getView registries, 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 are intval-cast and most values escaped, but identifiers/sort columns derived from metadata are interpolated more loosely. The database layer — setQuery/getQuoted/getEscaped discipline, 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 behind isAuthorized() 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).