User Docs Developer Docs | |

Models & Migrations

Plugin models and migrations follow standard CodeIgniter 4 conventions with two additional requirements: table name prefixes to avoid collisions, and namespace alignment for migration auto-discovery.

Models

Location and Namespace

plugins/MyPlugin/Models/ItemModel.php
namespace: PluginsMyPluginModels

Standard CI4 Model

<?php

namespace PluginsMyPluginModels;

use CodeIgniterModel;

class ItemModel extends Model
{
    protected $table         = 'mp_items';
    protected $primaryKey    = 'id';
    protected $returnType    = 'object';
    protected $useSoftDeletes = false;

    protected $allowedFields = [
        'title',
        'slug',
        'body',
        'status',
        'sort_order',
        'user_id',
    ];

    protected $useTimestamps = true;
    protected $createdField  = 'created_at';
    protected $updatedField  = 'updated_at';

    protected $validationRules = [
        'title' => 'required|min_length[3]|max_length[255]',
        'slug'  => 'required|is_unique[mp_items.slug,id,{id}]',
        'status'=> 'required|in_list[draft,published]',
    ];

    protected $validationMessages = [];
    protected $skipValidation     = false;
}

Table Naming

Always use a plugin-specific table prefix. See the Plugin Structure article for the prefix convention. The mp_ prefix shown above is for illustrative purposes — use a prefix unique to your plugin.

Accessing Models in Controllers

Use CI4's model() helper:

$model = model('PluginsMyPluginModelsItemModel');

Or instantiate directly:

$model = new PluginsMyPluginModelsItemModel();

The model() helper caches the instance for the request lifetime. Prefer it over direct instantiation in controllers.

Query Examples

// Find all published items, newest first
$items = $model->where('status', 'published')
               ->orderBy('created_at', 'DESC')
               ->findAll();

// Paginate
$items = $model->where('status', 'published')
               ->paginate(10, 'default', null, 'bootstrap_full');
$pager = $model->pager;

// Find by slug
$item = $model->where('slug', $slug)->first();

// Insert
$model->save([
    'title'  => 'New Item',
    'slug'   => 'new-item',
    'status' => 'draft',
]);

// Update
$model->update($id, ['status' => 'published']);

// Delete
$model->delete($id);

Return Type

Use 'object' as the return type (not 'array'). This is consistent with Pubvana core and makes template access via {{ item.title }} work correctly in .tpl views.

Migrations

Location and Namespace

plugins/MyPlugin/Database/Migrations/2024-01-15-000001_CreateMpItemsTable.php
namespace: PluginsMyPluginDatabaseMigrations

File Naming

CI4 migration file naming: YYYY-MM-DD-HHMMSS_DescriptiveName.php

2024-01-15-000001_CreateMpItemsTable.php
2024-01-15-000002_CreateMpCategoriesTable.php
2024-01-20-000001_AddBodyHtmlToMpItems.php

Use sequential timestamps within a date group (000001, 000002) to control order.

Migration Class

<?php

namespace PluginsMyPluginDatabaseMigrations;

use CodeIgniterDatabaseMigration;

class CreateMpItemsTable extends Migration
{
    public function up(): void
    {
        $this->forge->addField([
            'id' => [
                'type'           => 'INT',
                'constraint'     => 11,
                'unsigned'       => true,
                'auto_increment' => true,
            ],
            'title' => [
                'type'       => 'VARCHAR',
                'constraint' => 255,
            ],
            'slug' => [
                'type'       => 'VARCHAR',
                'constraint' => 255,
            ],
            'body' => [
                'type' => 'LONGTEXT',
                'null' => true,
            ],
            'status' => [
                'type'       => 'ENUM',
                'constraint' => ['draft', 'published'],
                'default'    => 'draft',
            ],
            'sort_order' => [
                'type'       => 'INT',
                'constraint' => 11,
                'default'    => 0,
            ],
            'user_id' => [
                'type'       => 'INT',
                'constraint' => 11,
                'unsigned'   => true,
                'null'       => true,
            ],
            'created_at' => [
                'type' => 'DATETIME',
                'null' => true,
            ],
            'updated_at' => [
                'type' => 'DATETIME',
                'null' => true,
            ],
        ]);

        $this->forge->addKey('id', true);
        $this->forge->addUniqueKey('slug');
        $this->forge->addKey('status');
        $this->forge->createTable('mp_items');
    }

    public function down(): void
    {
        $this->forge->dropTable('mp_items', true);
    }
}

How Migrations Run

Migrations run automatically during plugin activation:

// Inside PluginManager::activate()
$migrate = ConfigServices::migrations();
$migrate->setNamespace('Plugins\MyPlugin')->latest();

The setNamespace() call scopes the migration runner to your plugin's Database/Migrations/ directory. Only your plugin's migrations run — core migrations are not re-run.

If a migration fails, activation is aborted and the error is displayed in the admin panel.

Migration Best Practices

  1. Never edit a deployed migration — add a new migration to alter existing tables
  2. Always implement down() — even if just dropping the table
  3. Use IF EXISTS guards$this->forge->dropTable('mp_items', true) (the true adds IF EXISTS)
  4. Test the round-trip — run up(), verify schema, run down(), verify clean
  5. Foreign keys — add FK constraints only if you are certain the referenced table exists at migration time; prefer application-level integrity for cross-plugin references