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
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 intry/catch: a module problem must never break a core business operation. - Storefront widgets are contributed through
render points (
storefront_render_point:<name>listeners inConfig/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).
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.