CI4MS Architecture

System Blueprint

Understand the moving parts behind CI4MS—filters, routes, modules, caches, and tooling. Use this guide alongside the developer handbook when designing new features or debugging runtime behaviour.

Application Bootstrap & Request Lifecycle

Every request passes through app/Filters/Ci4ms.php:

app/Config/Filters.php performs dynamic filter discovery:

app/Config/Routes.php preloads settings, loads template routes, then includes each module's routes before defining front-end routes. A default template is shipped as app/Config/DefaultRoutes.php — copy it to Routes.php during setup.

CommonModel Abstraction

bertugfahriozer/ci4commonmodel powers the generic CRUD layer. Modules lean on helpers such as lists, selectOne, create, createMany, edit, remove, and isHave.

The backend BaseController instantiates CommonModel once, exposing it via $this->commonModel to child controllers.

Authentication & Authorization

Authentication is powered by CodeIgniter Shield (codeigniter4/shield):

Module Pattern

Each module (under modules/<Name>/) includes:

Use php spark make:module Foo (provided by ci4-cms-erp/ext_module_generator) to scaffold a new module skeleton.

Installation Flow

Web Installer (Modules\Install)

  • Copies env to .env, updates base settings, triggers migrations, and seeds defaults via InstallService.
  • Regenerates app/Config/Routes.php from the DefaultRoutes.php template.

CLI (php spark ci4ms:setup)

The Ci4msSetup command provides a fully automated installation path:

  • Accepts admin account details as command-line arguments.
  • Runs all database migrations across every module (--all).
  • Calls InstallService::createDefaultData() to seed modules, permissions, admin user, sample pages/blog entries, and settings.
  • Designed for use in CI/CD pipelines (Docker, GitHub Actions) where a browser-based installer is not practical.

Docker & CI/CD

CI4MS ships with a complete Docker environment:

Key Paths.php note: CI4 4.4+ requires a $supportDirectory property in app/Config/Paths.php pointing to the framework's ThirdParty directory. This is pre-configured in the repository:

public string $supportDirectory = __DIR__ . '/../../vendor/codeigniter4/framework/system/ThirdParty';

Caching & Configuration

Cache key Contents TTL
settings Decoded JSON settings values 24h
menus_{locale} Per-locale frontend menu tree 24h
{userId}_permissions Per-user permission flags Until invalidated
notif_unread_{userId} Per-user unread notification count 60s

Clear all caches with php spark cache:clear or selectively via cache()->delete($key).

Backend cache management (server-side allowlist)

Administrators can also purge caches from the backend Settings page ("Cache Management"). Modules\Settings\Libraries\CacheRegistry is a server-side allowlist that maps logical ids to exact keys and glob families (*_permissions, menus_*, notif_unread_*). The backend/settings/clearCache route (role=update) resolves those ids on the server before calling cache()->delete() / cache()->deleteMatching(), so the client only ever sends a logical id and never a raw pattern — glob injection is not possible.

Canonical settings decode

The settings key can be warmed by whichever entry point touches it first while it is cold — app/Config/Filters.php, the generated app/Config/Routes.php, or modules/Auth/Controllers/BaseController.php. All fillers must therefore use byte-identical decode logic: json_decode($value) gated on json_last_error() === JSON_ERROR_NONE && (is_object($decoded) || is_array($decoded)). A cold cache then always warms to a consistent stdClass shape no matter which path primed it.

Theme System

Content & SEO

Media, File Management & Logs

Backup & Restore

Notifications

Modules\Notifications delivers server-side, in-app notifications to administrators through a bell dropdown in the backend header and a full list page at backend/notifications. Instead of one row per recipient it uses a Model B design: a single global row plus per-user read state.

Data shape

Notifier::applyRelevance() — the single chokepoint

One method decides what a user may see, and it is the IDOR guard for the feed and for markRead alike. A user sees only broadcast rows, user rows whose target_value is their own id, and group rows for a Shield group they belong to — membership is resolved at read time from auth_groups_users. $userId is bound through db->escape() in the join and predicates as defense in depth.

Targeting, exclusions, and preferences

Channel abstraction

NotificationsConfig::$channels lists inapp, realtime, email, and webhook. Only inapp persists a row; realtime is the optional Redis/SSE signal, and email / webhook are no-op stubs. Another module registers its own channel by declaring public array $notificationChannels on its Config/{Name}Config.phpNotifier::resolveChannels() scans for it the same way Filters.php scans $csrfExcept.

Delivery success is judged only from channels that persist a row. The marker interface DurableChannelInterface (implemented by InAppChannel) adds no methods, so any new channel counts as transient until it opts in — fail-closed. NotificationBuilder::dispatch() tags every ChannelResult with its slug and durability, and the immutable DispatchOutcome separates three reportable states: complete, partial (some rows stored, some refused — surfaced as its own error, never as success), and stored-nothing, which also covers "no durable channel ran at all". The older "any channel returned ok" test was wrong under Model B, because InAppChannel could refuse a durable write for a security reason while RealtimeChannel still emitted its signal.

Producers

Realtime delivery (Redis-backed SSE) — optional, off by default

With NotificationsConfig::$realtimeEnabled off (the default) the bell polls the feed endpoint every 60s and nothing else happens. Switched on, notifications also push over Server-Sent Events. The transport is self-contained — no external hub, no JWT, no message broker — reusing the Redis + nginx + PHP-FPM stack already in place.

Operational note: ext-redis and the PHP version trap

ext-redis (phpredis) is declared under composer.json's suggest and is required for realtime. It must be installed for the PHP version that actually serves the site, not merely "somewhere on the machine":

Deployment & upgrade notes

Auto-Update & Release Signing

Modules\Settings\Libraries\UpdateService drives the backend one-click updater. It is fail-closed: every file it writes must appear in a manifest.json carrying a detached Ed25519 signature made by a key the installation already trusts. The threat model is a full supply-chain compromise — GitHub account, release and CDN all hostile. Under that model the updater still writes zero bytes, because the attacker does not hold the publisher's offline private key.

Verification pipeline

  1. GET /releases/latest — the release tag_name is validated against /^v?\d+(?:\.\d+){1,3}$/ before it is used anywhere.
  2. manifest.json and manifest.json.sig are fetched through that release's assets[].browser_download_url.
  3. ManifestVerifier checks the detached signature with sodium_crypto_sign_verify_detached() against every active key in the keyring.
  4. Version and repository binding are enforced.
  5. Every downloaded file is checked against its SHA-256 entry in the manifest.
  6. Only then does applyUpdate() touch the filesystem.

Verification runs on the raw HTTP response body. Re-serialising the payload (json_encode(json_decode($body))) before checking the signature is forbidden: canonicalization drift in key order, slash escaping, unicode escaping or a trailing newline silently invalidates a valid signature and can normalise away an attacker's edit. It is pinned by ManifestVerifierTest::testReserialisedManifestFailsSignature(), because it is exactly the "harmless cleanup" a future refactor invites.

The gates

All of the following must hold. None is advisory and there is no "continue anyway" escape hatch — not in the backend view, not in the API, not in the CLI.

The last three exist because the compare response is not covered by the manifest signature. Without them an attacker able to alter that response could serve a genuine signed manifest, mark every file removed, apply an update that changes nothing, bump .env, and pin the installation to its vulnerable build forever — it would consider itself up to date and never fetch that release again.

Failure classification

Failures are separated rather than collapsed into one message, because "GitHub is unreachable" and "someone tampered with this release" demand opposite reactions from an operator. All four abort.

Condition Message key Log level
Asset unreachable / network failure Settings.updateManifestUnreachable warning
Signature or version/repo binding failure Settings.updateSignatureInvalid critical
Keyring empty — nothing to trust Settings.updateNoTrustedKeys critical
Asset over the 8 MB download ceiling Settings.updateAssetTooLarge critical

Trusted keyring

Modules\Settings\Config\UpdateKeys holds a set keyed by key_id; each entry carries public_key (base64), status (active | revoked), added and fingerprint.

The repository ships with $keys = [], so auto-update is disabled out of the box. This is a deliberate fail-closed default: a keyring shipped with a key no operator ever verified out of band would be trust theatre. Until the publisher pastes in their own public key block, the updater reports Settings.updateNoTrustedKeys and applies nothing.

Key management (entirely offline)

The private key never enters GitHub or CI in any form. php spark ci4ms:release:keygen seals it into a keyfile with sodium_crypto_pwhash (argon2id) + sodium_crypto_secretbox, created via fopen('xb') inside a umask(0077) window — so it is never even briefly world-readable — and left at mode 0600. The keyfile must live outside ROOTPATH and outside public/; the command refuses to write inside either, rejecting symlinks and comparing paths case-insensitively. The password is read only through a hidden terminal prompt (stty -echo) and wiped with sodium_memzero(). The command prints the public key, its fingerprint and a ready-to-paste UpdateKeys.php block — never the private key.

Publishing a release (the order is load-bearing)

  1. Tag and check out the release with a clean working tree.
  2. php spark ci4ms:release:manifest --keyfile <path outside ROOTPATH> --version <x.y.z.w> — builds and signs in one step, then self-checks the result with its own ManifestVerifier and deletes both files if that check fails.
  3. Create a draft release and upload both assets under exactly the names manifest.json and manifest.json.sig.
  4. php spark ci4ms:release:verify --remote --tag v<x.y.z.w> — verifies the published assets from the outside, exactly as an installation would.
  5. Only now publish (--draft=false).

The draft step is mandatory rather than tidy: checkVersion() reads /releases/latest, which does not see drafts. Publishing first and uploading assets afterwards opens a window in which every installation sees a new version whose signature assets do not exist yet and reports updateManifestUnreachable.

CLI & Automation

Command Purpose
php spark ci4ms:setup Full automated installation (migrations + seeding)
php spark make:module <n> Scaffold a new module skeleton
php spark make:abview <n> Generate a backend view from the AdminLTE template
php spark create:route Rebuild app/Config/Routes.php from the template
php spark migrate --all Run all pending migrations
php spark cache:clear Clear all application caches
php spark ci4ms:geoip-update Download/refresh the local DB-IP City Lite database for session geo lookup (monthly cron)
php spark notifications:purge Manually delete read notifications (retention cleanup; dry-run unless --force, no cron installed)
php spark notifications:test Emit a test notification to verify the Model B dispatch path
php spark ci4ms:release:keygen Generate an Ed25519 signing keypair into a password-sealed keyfile and print the pasteable UpdateKeys.php block
php spark ci4ms:release:manifest Build and sign manifest.json for a release; refuses a dirty working tree and self-checks the result
php spark ci4ms:release:verify Verify a local or (with --remote --tag) a published manifest exactly as an installation would

Modules\Methods::moduleScan() inspects the router to align routes with permission records.

Development Tips

Common Data Tables

users, auth_groups, auth_identities, auth_groups_users, auth_permissions_pages, auth_users_permissions, modules, pages, blog, blog_categories_pivot, tags, tags_pivot, menu, settings, login_rules, notifications, notification_reads, notification_preferences, etc.

Consult module migrations for schema details.

Design with Insight

Use this architecture map to reason about dependencies and extension points. Pair it with the developer handbook and user guide for the full CI4MS story.