Add field into existing form
Inject a custom field into a core MikoPBX form from your module.
A module often needs to attach extra data to an existing core entity — an extension, a provider, a queue — without forking the Core. MikoPBX exposes two Web-UI hooks that together let a module surgically extend a form it does not own:
onBeforeFormInitialize(Form $form, $entity, $options)— add Phalcon form elements (inputs, selects, checkboxes) to a core form before it is built.onVoltBlockCompile(string $controller, string $blockName, View $view)— inject a partial Volt template into a named block of a core view, so those new elements actually render on the page.
To persist the value you add a model in your module and relate it to the core model with getDynamicRelations().
Throughout this page we extend the core Extensions edit form with a new field from a fictional module ModuleBlackList (config class BlackListConf, model BlackListNumbers, table m_BlackListNumbers). The real, working reference for this exact pattern is ModuleUsersUI, which uses both hooks to add an access group selector to the same Extensions form — see Extensions/ModuleUsersUI/Lib/UsersUIConf.php.
How the hooks fire
Both hooks are dispatched by the Core, so you never call them yourself.
onBeforeFormInitialize is invoked from BaseForm::initialize() for every admin form:
public function initialize($entity = null, $options = null): void
{
if ($entity === null) {
$entity = new stdClass();
}
PBXConfModulesProvider::hookModulesMethod(
WebUIConfigInterface::ON_BEFORE_FORM_INITIALIZE,
[$this, $entity, $options]
);
}The hook runs on all forms, so your first line must be a type guard. In MikoPBX 2025.1.1+ the form is frequently initialized with $entity as an empty stdClass (form data is loaded separately over the REST API), so never assume $entity is a populated model instance. Note also that $options is untyped in the signature (onBeforeFormInitialize(Form $form, $entity, $options)).
onVoltBlockCompile is invoked from the Volt hookVoltBlock(...) function while a template compiles. Core views declare named injection points like this:
For each hookVoltBlock call the Core gathers a partial path from every module (Core/src/AdminCabinet/Providers/VoltProvider.php), passing your config class the controller name (Extensions) and the block name (TabularMenu / AdditionalTab). The Extensions edit form exposes both TabularMenu (the tab strip) and AdditionalTab (the tab bodies) — that is the pair we target.
Step 1 — Add the form element
Override onBeforeFormInitialize in your config class. Guard on the exact core form class, then add Phalcon form elements.
Reading $entity->user_id is not enough on its own. Because the form is now initialized without an entity, the snippet above renders an unchecked box on every real page load. ModuleUsersUI solves this rather than tolerating it: when $entity is not an object or user_id is empty, it recovers the id from the dispatcher parameters and rebuilds a stand-in entity —
— see Extensions/ModuleUsersUI/Lib/UsersUIConf.php:175-188 and its resolveUserIdFromDispatcher() helper just below. Copy that shape if your field has to show a stored value on first render. Note the helper also has to cope with the URL parameter meaning different things across versions (in 2025.1.1 the modify-route parameter is the user_id; in older builds it is the extension id and needs a lookup).
ModuleUsersUI builds a richer set of elements (Text, Password, Check, Hidden, Select) in a dedicated helper and adds them all to the same form — see the verified reference in Extensions/ModuleUsersUI/App/Forms/ExtensionEditAdditionalForm.php (prepareAdditionalFields()), called from onBeforeFormInitialize in Extensions/ModuleUsersUI/Lib/UsersUIConf.php.
Step 2 — Render the field with a Volt partial
Adding the element to the form object is not enough — nothing renders it until you return a partial template for the relevant block. Override onVoltBlockCompile and match on the "$controller:$blockName" pair.
Return the partial path without the .volt extension, and return an empty string for blocks you do not handle. The path is resolved against the modules views root, so the string Modules/ModuleBlackList/Extensions/additionaltab maps to the physical file <moduleDir>/App/Views/Extensions/additionaltab.volt. ModuleUsersUI returns "Modules/ModuleUsersUI/Extensions/tabularmenu" for the file at Extensions/ModuleUsersUI/App/Views/Extensions/tabularmenu.volt — the same mapping.
Now create the two partials. The tab label:
And the tab body that renders the element you added in Step 1. Reference the field by the exact name you registered:
Compare with the working originals: Extensions/ModuleUsersUI/App/Views/Extensions/tabularmenu.volt and .../additionaltab.volt, which render module_users_ui_* fields with form.render(...).
Step 3 — Persist the value with a model relation
The field now renders and submits, but you still need somewhere to store it. Create a module model backed by your own table and relate it to the core model with a static getDynamicRelations() method.
When a core model is initialized (ModelsBase::initialize() → addExtensionModulesRelations()), the Core scans each enabled module's Models/ directory and, when a model class declares getDynamicRelations(), calls it so the module can attach relationships to the core model being loaded:
Declare the relation from your model toward the core Users model (the entity behind an extension):
getDynamicRelations() declares a Phalcon relationship (alias, foreign key, cascade/restrict behaviour) — it ties your record to the core record so deletes cascade and referential integrity holds. It does not read the submitted form value. Capturing the value the user typed into module_black_list_blocked and writing it to BlackListNumbers is a separate save step (intercepting the save in your own controller/REST handler). The relation alone does not auto-persist the field.
Real, verified examples of getDynamicRelations():
Extensions/ModuleAutoprovision/Models/ModuleAutoprovisionUsers.php— a livehasManyrelation toUsers(the pattern reproduced above).Extensions/EXAMPLES/WebInterface/ModuleExampleForm/Models/ModuleExampleForm.php— a documentedgetDynamicRelations()stub showing abelongsToto a coreProvidersmodel.
Putting it together
Add the form element
onBeforeFormInitialize(Form $form, $entity, $options)
BaseForm::initialize()
Render the element
onVoltBlockCompile(string $controller, string $blockName, View $view)
Volt hookVoltBlock(...)
Relate to the core model
getDynamicRelations(&$calledModelObject) (static, on your model)
ModelsBase
Verify your work: open the core form in the admin UI. If the tab/label appears but the field is missing, your onVoltBlockCompile partial path or block name is wrong. If the element is in the form object but nothing shows, the Volt partial is not rendering it (check the form.render(...) field name). If the field shows but your data does not survive a reload, the relation is fine but the save step is missing.
See also
Last updated
Was this helpful?