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
- Never edit a deployed migration — add a new migration to alter existing tables
- Always implement
down()— even if just dropping the table - Use
IF EXISTSguards —$this->forge->dropTable('mp_items', true)(thetrueaddsIF EXISTS) - Test the round-trip — run up(), verify schema, run down(), verify clean
- 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