Best practices
Conventions, PHP 8.4 idioms, and anti-patterns to avoid when building MikoPBX modules.
Best practices
This page is the rulebook for writing MikoPBX modules that are clean, secure, and indistinguishable from Core code. It targets MikoPBX 2025.1.1+, PHP 8.4, Phalcon 5.9.3. Everything here is verified against the Core source and the example modules shipped under Extensions/EXAMPLES/.
The running example throughout this documentation is a fictional module ModuleBlackList (config class BlackListConf, main class BlackListMain, model BlackListNumbers, table m_BlackListNumbers, front-end module-black-list-index.js). Every rule below is also anchored to a real example module by repo-relative path so you can read working code.
File headers
Every PHP file in a module starts the same way and never emits a closing tag:
<?php
declare(strict_types=1);
namespace Modules\ModuleBlackList\Lib;declare(strict_types=1);is mandatory on every file (anti-pattern #10 below). It makes scalar type hints reject silent coercion — astringpassed whereintis declared throwsTypeErrorinstead of becoming0.No closing
?>. A trailing tag risks emitting stray whitespace that corrupts headers or generated config files.
See a real header in Extensions/EXAMPLES/AMI/ModuleExampleAmi/Lib/ExampleAmiConf.php.
The DI import rule
This single import trips up almost every new module. Phalcon 5 moved the container class into its own sub-namespace:
Confirmed in real module code at Extensions/EXAMPLES/AMI/ModuleExampleAmi/Lib/WorkerExampleAmiAMI.php ($di = Di::getDefault();) and Extensions/EXAMPLES/REST-API/ModuleExampleRestAPIv2/Lib/RestAPI/Backend/Actions/GetUsersAction.php.
Naming conventions
Consistent names are not cosmetic — the loader, the table-name resolver, the asset pipeline, and the REST router all derive paths from these patterns. Deviating breaks autodiscovery silently.
Element
Convention
ModuleBlackList example
Module ID / directory
Module{Feature} (PascalCase)
ModuleBlackList, dir Extensions/ModuleBlackList/
moduleUniqueID
identical to Module ID
ModuleBlackList
Config class
{Feature}Conf
BlackListConf
Main logic class
{Feature}Main
BlackListMain
Setup class
always PbxExtensionSetup
Setup/PbxExtensionSetup.php
Model
{Entity}
BlackListNumbers
DB table
m_{Entity} (auto from model)
m_BlackListNumbers
Web controller
Module{Feature}Controller
ModuleBlackListController
Form
Module{Feature}Form
ModuleBlackListForm
Worker
Worker{Feature}{Type}
WorkerBlackListAMI
REST resource classes
Controller / Processor / DataStructure
Lib/RestAPI/Numbers/Controller.php
REST action
{Verb}{Entity}Action
GetListAction, SaveRecordAction
JS source / compiled
module-{kebab}-{action}.js
public/assets/js/src/module-black-list-index.js
CSS
module-{kebab}-{action}.css
public/assets/css/module-black-list-index.css
Translation key prefix
module_{feature}_
module_black_list_NumberColumn
Dialplan context
[module-{kebab}-{purpose}]
[module-black-list-check]
Asset names carry the controller action suffix (-index, -modify, …) because the Core loads them per action — see ModuleExampleFormController::indexAction() / modifyAction() in Extensions/EXAMPLES/WebInterface/ModuleExampleForm/.
Namespaces
The PHP namespace mirrors the directory layout exactly (PSR-4):
REST API path scheme
See the live v3 implementation in Extensions/EXAMPLES/REST-API/ModuleExampleRestAPIv3/Lib/RestAPI/Tasks/.
PHP 8.4 idioms
Write modern PHP everywhere except Phalcon model column properties (see the model exception immediately after).
Typed properties on non-model classes
Conf, Main, Worker, controllers, and service classes get full property types:
Constructor property promotion
match over switch and over dynamic dispatch
Use match for request routing inside Conf hooks. It is exhaustive, returns a value, and uses strict comparison. This is the canonical REST callback pattern.
createErrorResult() is not a method of MikoPBX\Modules\Config\ConfigClass — do not assume it is inherited. It is a small private helper that each module declares for itself, which is why it is spelled out above. The shipping examples do the same: Extensions/EXAMPLES/AMI/ModuleExampleAmi/Lib/ExampleAmiConf.php and Extensions/EXAMPLES/REST-API/ModuleExampleRestAPIv1/Lib/ExampleRestAPIv1Conf.php each define their own copy.
This is exactly the shape used in Extensions/EXAMPLES/AMI/ModuleExampleAmi/Lib/ExampleAmiConf.php (moduleRestAPICallback). match is also the prescribed fix for the dynamic-dispatch security hole — see S6.
Named arguments and backed enums
Named arguments make multi-flag calls self-documenting:
Full type declarations on methods
Every method declares parameter types and a return type — including void and : never. Anti-pattern #11 flags untyped methods, and covers the one exception where a type declaration actually breaks the ORM.
The Phalcon model exception
Model column properties are the one place you do NOT apply the PHP 8.4 typed-property rules. This is intentional and matches the Core models — it is NOT an anti-pattern, and code reviewers/linters must not flag it.
SQLite stores everything as text, and Phalcon hydrates model columns from those text values. The Core models therefore follow this exact pattern:
Why each rule holds:
public $id;untyped — the row has noiduntil aftersave(); a typed property would have to be uninitialized then suddenly populated, fighting the ORM.?stringcolumns with''default — values arrive as strings from SQLite; nullable absorbsNULLcolumns.Integers-as-strings (
'0'/'1') — flag columns round-trip as text; declaring them?stringavoids lossy coercion.Nullable int FKs (
?int … = null) — an unset foreign key is genuinelyNULL.
This is confirmed verbatim in the Core models src/Common/Models/Sip.php (public $id;, public ?string $disabled = '0';), src/Common/Models/Extensions.php (public ?int $userid = null;), and src/Common/Models/CallQueues.php. Use those as your reference, not example modules — see also Data model.
Code-quality anti-patterns
Each entry lists how to spot it, why it hurts, and the fix. They are ordered by severity, and the numbers are local to this page.
#1 [CRITICAL] MikoPBXVersion.php in new modules
Detection. A MikoPBXVersion.php file or MikoPBXVersion::getDefaultDi() calls in a module whose module.json declares min_pbx_version ≥ 2025.1.1.
Problem. MikoPBXVersion was a legacy compatibility shim. On a 2025.1.1+ baseline it is dead weight, and it hides the real DI container behind an indirection.
Fix. Delete the file and call the container directly:
Di::getDefault() is the pattern used throughout the examples, e.g. Extensions/EXAMPLES/AMI/ModuleExampleAmi/Lib/WorkerExampleAmiAMI.php.
#2 [CRITICAL] shell_exec for reloads instead of framework methods
Detection. shell_exec(...) or exec(...) in Lib/*Conf.php invoking asterisk -rx, safe_asterisk, or service scripts.
Problem. Shelling out to reload Asterisk bypasses MikoPBX process management, skips config regeneration, races with other reloads, and runs unescaped (see S3).
Fix. Use the framework's reload entry points. The two you will actually call from a module are:
This is the real pattern in Extensions/EXAMPLES/AMI/ModuleExampleAmi/Lib/ExampleAmiConf.php (onAfterModuleEnable() calls System::invokeActions(['manager' => 0])) driving Extensions/EXAMPLES/AMI/ModuleExampleAmi/Lib/ExampleAmiMain.php (startAllServices() loops over getModuleWorkers() and calls Processes::processPHPWorker()).
System::invokeActions() recognizes only the manager and cron keys — other keys are silently ignored. It is a thin wrapper that dispatches to WorkerModelsEvents::invokeAction(ReloadManagerAction::class) / ReloadCrondAction::class, and is itself marked @deprecated in favour of calling MikoPBX\Core\Workers\WorkerModelsEvents::invokeAction(...) directly. The shipped example modules still use the System::invokeActions(['manager' => 0]) wrapper, so it remains the most readable choice for module code today; prefer the WorkerModelsEvents::invokeAction() form if you want the non-deprecated call. For dialplan and SIP regeneration, the modern Core APIs are MikoPBX\Core\Asterisk\Configs\ExtensionsConf::reload() and MikoPBX\Core\Asterisk\Configs\SIPConf::reload(). The old facades PBX::dialplanReload() / PBX::sipReload() (and the whole MikoPBX\Core\System\PBX class) are @deprecated — do not use them in new code. The one non-deprecated reload method on PbxConf is MikoPBX\Core\System\Configs\PbxConf::coreReload().
#3 [HIGH] Monolithic Conf class
Detection. Lib/{Feature}Conf.php over ~500 lines, or business logic (HTTP calls, parsing, ORM loops) living inside hook methods.
Problem. The Conf class exists to implement hooks and delegate. Stuffing logic into it makes it untestable and couples lifecycle events to behavior.
Fix. Extract behavior into {Feature}Main. Hooks stay thin:
Extensions/EXAMPLES/AMI/ModuleExampleAmi/ is the canonical split: ExampleAmiConf.php (hooks) delegates to ExampleAmiMain.php (logic).
#4 [HIGH] Phantom model fields
Detection. Code reads $record->someField where someField is not a declared column property on the model.
Problem. Phalcon returns null for an undeclared property access instead of erroring, so the bug is silent — branches misfire, data goes unsaved.
Fix. Only access properties that are declared with column annotations on the model. If you need a new field, add it to the model and to the installer's migration (see Data model). Reference real models in src/Common/Models/.
#5 [MEDIUM] die() / exit() in library and worker classes
Detection. die( or exit( in Lib/*.php or bin/*.php (worker code).
Problem. Hard exits skip destructors and finally blocks, prevent graceful worker shutdown, and make the code untestable.
Fix. In a worker, log and flag for restart; in a library, throw.
#6 [MEDIUM] 1*$variable casting idiom
Detection. 1*$var or 1*shell_exec(...).
Problem. A PHP 4-era trick to coerce to a number. Obscure and bypasses strict typing intent.
Fix. Cast explicitly:
#7 [MEDIUM] md5(print_r(...)) for change detection
Detection. md5(print_r($data, true)).
Problem. print_r() output is ambiguous — distinct structures can render identically — so the hash misses real changes.
Fix. Hash a canonical, unambiguous encoding:
#8 [MEDIUM] File-based IPC instead of Redis
Detection. file_put_contents(... json_encode ...) paired with file_get_contents(... json_decode ...) used to pass state between processes.
Problem. Files have no atomicity or TTL — workers race, and stale state accumulates.
Fix. Use the shared Redis service from the DI container, with an expiry:
#9 [MEDIUM] Manual worker killing
Detection. Processes::killByName(...) inside Lib/*Conf.php to restart workers.
Problem. Manually killing PIDs fights the safe-scripts watchdog, which will restart workers on its own schedule, producing flapping.
Fix. Let the framework manage worker lifecycle. Enumerate your workers through getModuleWorkers() (declared in MikoPBX\Modules\Config\ConfigClass) and hand each to Processes::processPHPWorker(), which restarts-or-starts as needed:
This is startAllServices() in Extensions/EXAMPLES/AMI/ModuleExampleAmi/Lib/ExampleAmiMain.php.
#10 [LOW] Missing strict_types and wrong DI import
Covered above under File headers and The DI import rule. Detection: grep -L "declare(strict_types" *.php finds files missing the declaration; use Phalcon\Di; (without \Di) is the wrong import.
#11 [LOW] Untyped methods and properties — and the one place types break the ORM
Detection. A method with no parameter types or no return type, or a non-model class property declared bare (public $logPath;). grep -nE 'function [a-zA-Z]+\([^)]*\)\s*\{' Lib/*.php finds methods missing a return type.
Problem. Without declared types, declare(strict_types=1) has nothing to enforce: a null flows into a string concatenation, an empty array satisfies a "count" parameter, and the failure surfaces three call frames later as a nonsense value rather than a TypeError at the boundary. On a PHP 8.4 baseline there is no reason to give that up — and static analysis (phpstan) can only reason about what you declare.
Fix. Type every parameter, every return, and every non-model property:
The exception: never type a Phalcon model's primary key
This rule stops at the model layer, and getting it wrong produces a fatal error rather than a warning — which is why it is worth stating as its own anti-pattern rather than a footnote.
A typed, non-nullable int $id is uninitialized on a freshly constructed model — PHP typed properties have no implicit default. A new record has no id until the INSERT returns one, so any read of the property before that point raises:
Phalcon reads the primary key while deciding whether save() is an INSERT or an UPDATE, so the very first save() on a new record blows up. Declaring public ?int $id = null; avoids the fatal but still fights the ORM's own hydration; the Core convention is the plain untyped public $id;.
This is not theory — it is what every Core model does: Core/src/Common/Models/Sip.php:68 and Core/src/Common/Models/Extensions.php:79 both declare public $id;. The same file pair shows the rest of the column convention: Sip.php:82 (public ?string $disabled = '0';) and Extensions.php:107 (public ?int $userid = null;).
The full rationale for all four column patterns is in The Phalcon model exception above.
Security anti-patterns
Modules run inside the PBX with full filesystem and DB access; AGI scripts run as root. A vulnerable module compromises the whole appliance. Fix every S-level finding before any code-quality work. For the authorization model and how endpoints are protected, see Rights and authentication.
S1 [CRITICAL] Unauthenticated endpoints
Detection. A REST route registered with noAuth = true — the 6th element ($additionalRoute[5]) of a route array returned by getPBXCoreRESTAdditionalRoutes(). When that flag is true, Request::thisIsModuleNoAuthRequest() matches the URI and lets the request through without authentication.
Problem. An unauthenticated route that mutates state, originates calls, or returns sensitive data is an open door.
Fix. Never set noAuth = true for endpoints that modify settings, originate calls, expose credentials/tokens, touch CDR or recordings, or serve files by user path. Keep them behind the default auth, or verify an HMAC/token inside the handler.
S2 [CRITICAL] SQL injection in findFirst / find
Detection. String interpolation inside a condition: Model::findFirst("col='{$x}'").
Problem. User input concatenated into the WHERE clause is classic SQL injection.
Fix. Always use parameterized binds:
S3 [CRITICAL] Command injection
Detection. A variable inside shell_exec / Processes::mwExec / exec without escapeshellarg(). Especially dangerous in agi-bin/ (root) and workers handling external data (CDR filenames, SIP headers, API payloads).
Problem. Unescaped input becomes shell syntax — arbitrary command execution.
Fix. Wrap every variable:
S4 [CRITICAL] Path traversal / arbitrary file read
Detection. fopen / file_get_contents / readfile / fpassthru on a user-supplied path with no validation against an allowed base directory.
Problem. ../../etc/... escapes the intended directory and reads arbitrary files.
Fix. Resolve the real path and confirm it is inside the allowlisted base:
S5 [CRITICAL] Reflected XSS
Detection. $_REQUEST / $_GET / $_POST echoed into HTML or inline JS without escaping.
Problem. Attacker-controlled markup executes in the admin's browser.
Fix. Escape on output:
S6 [HIGH] Dynamic dispatch from user input
Detection. $this->$action(...) / self::$method(...) where the name comes from the request.
Problem. A user-controlled method name exposes every public method of the class as a callable endpoint.
Fix. Replace variable dispatch with an explicit match allowlist (the same idiom used for REST routing):
Quick checklist before you ship
Related reading: The module configuration class · Data model · Rights and authentication.
Last updated
Was this helpful?