For the complete documentation index, see llms.txt. This page is also available as Markdown.

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.

Reference: Phalcon 5.x ORM models — https://docs.phalcon.io/5.0/en/db-models. MikoPBX 2025.1.1+ ships Phalcon 5.9.3 on PHP 8.4; annotation syntax (@Column, @Primary, @Identity) is unchanged from earlier Phalcon, but the runtime APIs are Phalcon 5.

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.

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.

The commented-out template in Extensions/ModuleTemplate/Models/ModuleTemplate.php (the getDynamicRelations() body) is the canonical reference for this hook, including the rule that the message value must duplicate the alias prefixed with Models\.

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?