Skip to main content

Module structure

A module is a self-contained directory in app/Modules/<Name>/:

app/Modules/MyModule/
├── module_info.php # metadata: name, version, description (single source of the version)
├── Config/
│ ├── Routes.php # module routes
│ ├── Events.php # subscriptions to core events, render points (optional)
│ └── Registrar.php # contributions to core registries: menu, CSRF, scheduler... (optional)
├── Controllers/
│ ├── MyModule.php # public controllers (storefront)
│ └── Admin/
│ └── MyModuleAdmin.php# admin controllers
├── Models/ # CI4 models of the module
├── Libraries/ # business logic, install()/ensureSchema() for own tables
│ └── ModuleUpdater.php # self-update (typical component)
├── Commands/ # spark commands (scheduled tasks, maintenance)
├── Resources/
│ └── install.sql # optional idempotent SQL applied by the store installer
├── Views/
│ └── admin/ # admin Twig views
└── .baseline.json # version file manifest (generated at build time)

module_info.php

The single source of truth for the module version. The self-updater and the update server read it; every release must bump the version here.

Making the module visible to the core

CI4 auto-discovery loads Config/Routes.php, Config/Events.php and Config/Registrar.php only for namespaces it knows. A first-party module therefore needs one line in app/Config/Autoload.php $psr4 ('App\Modules\MyModule' => APPPATH . 'Modules/MyModule'); without it the routes 404 into the slug fallback and the events stay silent — composer dump-autoload is not enough. Modules installed from the store do not touch that file: the installer writes the namespace into writable/store_modules.json, which Config\Autoload merges at boot.

The module also needs a row in the components table (identif, enabled, active); the store installer creates it, a first-party module creates it in its install step.

Data access

New modules use the CI4 Query Builder (\Config\Database::connect()) or CodeIgniter\Model only — no Propel (App\Propel\*Query::create(), joinWithI18n, Propel models) in new code. Reference modules written this way: CloudStorage, Comments/Controllers/CommentsApi.php.

A module owns its schema: create tables in an idempotent install()/ensureSchema() (CREATE TABLE IF NOT EXISTS, INSERT IGNORE for seed rows) and call it from the install path or lazily before first use. Modules do not ship CI4 migrations; core migrations are reserved for the core schema (and spark migrate on a live site is safe — the baseline is guarded).

Config/Registrar.php — contributions to core registries

Instead of editing app/Config/*, a module declares what it needs in a static ModuleExtensions() method; CI4 registrar auto-discovery merges it into Config\ModuleExtensions:

namespace App\Modules\MyModule\Config;

class Registrar
{
public static function ModuleExtensions(): array
{
return [
'csrfExempt' => ['my_module/callback'], // URI prefixes without CSRF
'lsCacheNever' => ['my_module/exchange'], // never page-cached
'adminMenu' => [['section' => 'settings', 'item' => [
'identifier' => 'my_module', 'text' => cms_lang('My module', 'admin_menu'),
'link' => '/admin/my-module', 'class' => '', 'id' => '', 'pjax' => '', 'icon' => '',
]]],
'jobHandlers' => ['my_module:sync' => [Handler::class, 'run']],
'scheduledTasks' => ['my-module-sync' => [
'command' => 'my_module:sync', 'every' => 900, // or 'at' => '04:10'
'label' => 'My module: sync', 'module' => 'my_module', 'enabled' => true,
]],
'adminTranslations' => ['my_module' => ['Save' => ['uk_UA' => 'Зберегти', 'ru_RU' => 'Сохранить', 'en_US' => 'Save']]],
];
}
}

Supported keys: csrfExempt, lsCacheNever, moduleEnabledAlways (machine endpoints that must not 404 when the module is disabled), adminMenu, jobHandlers, scheduledTasks, adminTranslations.

Scheduled tasks

A scheduledTasks entry is a spark command (Commands/) plus every (seconds) or at (HH:MM daily). The internal scheduler runs it in a separate subprocess and shows it in Settings → Scheduler (/admin/scheduler), where the owner can toggle it or run it now. A task is only eligible when the module has an active components row and the command exists in the spark registry — renaming a command without updating the entry silently parks the task. See Scheduler.

Core integration rules

Don't hand-edit core configs

If a module registers a class in core configuration (a filter in Config/Filters.php, a route hook, etc.), removing or updating the module without that class takes the whole site down — the core tries to load a missing class on every request.

The right way is to integrate via events (Config/Events.php inside the module) and namespace auto-discovery. Then a disabled or deleted module simply stops working without breaking the system.

  • Every event listener must check that the module is enabled (module_enabled('my_module')) and wrap its body in try/catch: a module problem must never break a core business operation.
  • Storefront widgets are contributed through render points (storefront_render_point:<name> listeners in Config/Events.php), never by writing into theme files.
  • If the core lacks a seam you need, add a generic event or registry key to the core — not a call to your module's class.
  • Module files belong to the site owner; after a manual deploy remember file ownership and an opcache reset.

Admin controllers

Module admin screens render through the module's Twig views (Views/admin/*.twig) in the admin theme style (admin-redesign, rd-* classes).

SimpleXML and Twig

Never pass a SimpleXMLElement straight into a view — Twig crashes on its iterator. Convert to an array first: json_decode(json_encode($xml), true).

Self-update

A typical module ships Libraries/ModuleUpdater.php and the admin actions check_update/do_update: version check against the update server, package download, backup of the current version, unpacking, verification against .baseline.json.