Data model
Phalcon 5 models, table conventions, relationships and metadata in a MikoPBX module.
Start every module from a clean data structure. A module owns its own SQLite database, and each table is described by a Phalcon 5 ORM model class. The model is the single source of truth for the schema: MikoPBX reads the @Column annotations and auto-creates the matching table during installation. You never write CREATE TABLE SQL by hand.
This page covers how to declare a model, the column conventions MikoPBX expects, how tables are generated, and how to wire relationships — including dynamic relations to core models.
For the surrounding directory layout and class roles, see module-anatomy.md. For how tables get created on install, see module-installer.md. For the core models you will relate to, see core.md.
Where models live
Model classes live in the module's Models/ directory. The class name and file name must match (PSR-4), and the namespace is Modules\{ModuleUniqueID}\Models.
ModuleBlackList
├── Models
│ ├── BlackListNumbers.php
│ └── BlackListSettings.php
├── Lib
│ └── BlackListConf.php
├── module.json
└── ...Our running example module, ModuleBlackList, stores blocked caller numbers in a BlackListNumbers model backed by the table m_BlackListNumbers.
The base class
A module model extends MikoPBX\Modules\Models\ModulesModelsBase:
ModulesModelsBase (in Core/src/Modules/Models/ModulesModelsBase.php) does one critical thing in its initialize(): it derives the module's unique ID from the namespace (Modules\{UniqueID}\Models\...) and points the model at the module's own database connection via setConnectionService(). This is why a module model reads/writes the module DB rather than the main PBX DB. It then calls parent::initialize(), which is ModelsBase::initialize() — enabling snapshot tracking (keepSnapshots(true)), change events, and dynamic relations.
Declaring a model
The model body is the table schema. Each public property annotated with @Column becomes a column; the annotation defines the SQL type.
setSource() — the table name
initialize() must call $this->setSource('m_BlackListNumbers') to bind the model to its table, then call parent::initialize(). The base class does the connection wiring, so never skip parent::initialize() — without it the model has no DB connection and no event handling.
This mirrors the core models exactly. Sip calls $this->setSource('m_Sip'), Extensions calls $this->setSource('m_Extensions') (see Core/src/Common/Models/Sip.php and Core/src/Common/Models/Extensions.php).
Column conventions
MikoPBX/Phalcon models follow a strict set of conventions. They look unusual compared to plain PHP DTOs, but they are correct and intentional — the linter explicitly exempts them from the "missing type declaration" rule (see anti-pattern #15 in skills/mikopbx-module/reference/anti-patterns.md of mikopbx/agent-skills).
Primary key: always untyped public $id
The primary key is declared without a PHP type hint. This matches every core model — Sip, Extensions, Providers all use untyped public $id; (confirmed in Core/src/Common/Models/Sip.php and Core/src/Common/Models/Extensions.php). The @Identity annotation makes it an auto-increment column. Do not write public int $id; for the primary key — Phalcon assigns it from the database, and an uninitialized typed property throws a "must not be accessed before initialization" error.
String columns: nullable, with a default
Use a nullable type (?string) and an empty-string default. This keeps the property readable before the first save() and matches core models such as Sip::$uniqid (public ?string $uniqid = '';).
Integers stored as string in SQLite
SQLite has dynamic typing, and MikoPBX stores small flags as 1-character strings. A boolean flag is declared as a string column of length 1 with a string default '0' / '1':
This is the exact pattern used by core models — Extensions and Sip declare flags as @Column(type="string", length=1, nullable=true, default="0"). Read them as '0'/'1' strings, not PHP booleans. When you need a real integer column (counters, priorities), use type="integer":
Extensions/ModuleTemplate/Models/ModuleTemplate.php shows both styles: text and dropdown fields as type="string", and checkbox/toggle fields as type="integer", default="1".
Nullable integer foreign keys
A column that references another table's id is a nullable integer that is null when there is no link:
Defaults are filled automatically
ModelsBase::beforeValidationOnCreate() reads the annotation default values from metadata and applies them to any property still unset on insert (see Core/src/Common/Models/ModelsBase.php). So the default="0" in the annotation — not just the PHP property default — is what backs a new row.
How tables are created
You do not run migrations manually. During installation, PbxExtensionSetupBase::createSettingsTableByModelsAnnotations() scans the module's Models/*.php files and builds or upgrades each table from its annotations:
Each model is handed to UpdateDatabase::createUpdateDbTableByAnnotations(), which creates the table on first install and adds new columns on upgrade when you add new @Column properties. This is why the rule for adding a field is "edit the model, then re-install" — see module-installer.md for the full install flow.
The module database (SQLite) is created at:
Verify a table was created:
Naming requirements
The table name passed to
setSource()must be unique within the module.The model class name must be unique within the module.
Prefix module tables with
m_so they never collide with core tables.Beware of column names that begin with a PHQL keyword. A confirmed case: the PHQL lexer splits
note_textintoNOT+e_text, and the query fails with "Column 'e_text' doesn't belong to any of the selected models". Other keyword-prefixed names may behave the same, so when in doubt bracket-escape the column in everyconditionsstring:'[note_text] = :text:'.
Relationships
Declare relationships inside initialize() (after setSource(), before parent::initialize()), using the standard Phalcon methods belongsTo, hasOne, hasMany, hasManyToMany.
ModuleTemplate shows a hasOne link from a module column to the core Providers model:
The foreignKey.action controls delete behaviour and is enforced by ModelsBase::beforeDelete() (it walks the relations and blocks a delete that would orphan related rows). Use Relation::ACTION_RESTRICT to block, Relation::ACTION_CASCADE to cascade, or Relation::NO_ACTION to skip enforcement. The core architecture (Users → Extensions → Sip, Providers → routing tables) is summarised in core.md.
Dynamic relations to core models — getDynamicRelations()
The relation above lets your model reach a core model. To make a core model aware of your module (e.g. so deleting a Providers row checks your table first, or so the admin UI shows the dependency), implement the static hook getDynamicRelations().
MikoPBX calls it automatically. In ModelsBase::initialize(), addExtensionModulesRelations() iterates every enabled module's Models/*.php, and for each class that defines getDynamicRelations() it invokes the method, passing the core model instance being initialised (see Core/src/Common/Models/ModelsBase.php). Your hook inspects which model is calling and, if it is the one you want to extend, adds the relation onto it.
Public IDs: the v3 convention
Several core models expose an internal auto-increment id plus a separate public string identifier — for example Sip::$uniqid (public ?string $uniqid = '';). The same pattern is recommended for module tables exposed over the REST API, so you never leak sequential IDs (which enable enumeration).
The v3 table convention is m_{Module}_{Table} with a uniqid public column. Extensions/EXAMPLES/REST-API/ModuleExampleRestAPIv3/Models/Tasks.php demonstrates it:
Using the model
Once installed, use standard Phalcon ORM calls. Always parameterise conditions (string interpolation in find/findFirst is a SQL-injection vector — anti-pattern S2) and always check the result of save().
Checklist
Last updated
Was this helpful?