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.
Every request passes through app/Filters/Ci4ms.php:
/install if .env is missing
(fresh setup).
maintenance-mode if the cached settings
flag is enabled.
after(), caches the menu tree if it is absent (24h
TTL).
app/Config/Filters.php performs dynamic filter discovery:
modules/*/Filters and the active theme filters to
register aliases.
Modules\Backend\Config\BackendConfig::$csrfExcept.
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.
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 is powered by
CodeIgniter Shield (codeigniter4/shield):
Modules\Auth\Libraries\AuthLibrary handles
login/logout, remember-me cookies, lockouts, password reset tokens,
and email notifications. It sets session keys
(logged_in, redirect_url) and caches user
permissions per user ID.
auth_groups,
auth_identities, auth_groups_users, with
proper foreign keys.
Modules\Backend\Filters\BackendAfterLoginFilter:
AuthLibrary::has_perm(); otherwise redirects to
/backend/403.
info.xml,
screenshot.png) and warms the settings cache.
Modules\Backend\Controllers\BaseController
centralizes:
Modules\Users\Models\UserscrudModel::loggedUser().
AuthLibrary::sidebarNavigation()), settings,
encrypter, mail config, and default view data
($this->defData).
auth_permissions_pages (module/page CRUD flags) and
auth_users_permissions (user overrides).
Modules\Methods manages these tables and can auto-scan
routes to populate permissions.
Modules\Backend\Filters\BackendLogFilter (IP, user
agent, action, module).
Modules\Auth\Models\UserSessionModel::recordLogin()
can enrich a session with approximate city/region/country, resolved
entirely against a local DB-IP City Lite database
(MMDB) via Modules\Auth\Libraries\GeoLocator
(maxmind-db/reader) — the IP address never leaves the
server. Gated by the Auth.geoLookupEnabled setting
(default false) and returns null on any
failure, so it never breaks login. The database is
downloaded/refreshed with php spark ci4ms:geoip-update
(atomic swap, flock-guarded, monthly cron) and lives
outside the web root under writable/geoip/. DB-IP data
is CC BY 4.0 and requires attribution.
Each module (under modules/<Name>/) includes:
Config/Routes.php — backend routes and metadata
(role, etc.).
Config/*.php — module-specific configuration.
Controllers/ — usually extend the backend base
controller.
Models/ — data access layer.Views/ — backend UI templates.Libraries/, Helpers/,
Language/, Filters/,
Database/Migrations/.
Use php spark make:module Foo (provided by
ci4-cms-erp/ext_module_generator) to scaffold a new
module skeleton.
Modules\Install)env to .env, updates base
settings, triggers migrations, and seeds defaults via
InstallService.
app/Config/Routes.php from the
DefaultRoutes.php template.
php spark ci4ms:setup)
The Ci4msSetup command provides a fully automated
installation path:
--all).
InstallService::createDefaultData() to seed
modules, permissions, admin user, sample pages/blog entries, and
settings.
CI4MS ships with a complete Docker environment:
.docker/Dockerfile — PHP 8.2 + Apache, with all
required extensions pre-installed.
.docker/apache/000-default.conf — Apache virtual host
pointing to public/.
.docker/php/php.ini — PHP configuration tuned for
CI4MS.
docker-compose.yml — Orchestrates app,
db (MariaDB), and phpmyadmin services.
.github/workflows/docker-test.yaml — GitHub Actions
pipeline that builds the image, waits for the database, runs
php spark ci4ms:setup, performs a PHP syntax check, and
validates HTTP responses.
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';
| 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).
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.
php spark cache:clear / cache()->clean() is
never invoked by this panel.
shield_auth_dynamic_config) is a protected entry and
can never be cleared through either the "Clear Selected" or the
"Clear All" action.
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.
$settings->group->key). Only JSON-array levels
stay PHP arrays and remain foreach-iterable.
(object) json_decode(...)-cast the top level: it
leaves every nested level as an array and produces a mixed shape that
fatals under PHP 8 when a view reaches into it with array syntax.
maintenanceMode and
convertWebp are read as raw string values, not through a
->scalar wrapper.
public/templates/<theme>/ (plus
optional app-level template directories).
Modules\Theme handles ZIP uploads to
writable/tmp/, detects duplicates, installs
assets/views/helpers, and copies
Database/Migrations/ if present.
info.xml or
screenshot.png are missing.
App\Controllers\Home renders front pages and blogs,
filters out inactive pages (isActive = 0),
parses inline shortcodes
(CommonLibrary::parseInTextFunctions()), assembles meta
tags (Ci4msseoLibrary), and loads categories, tags,
authors, breadcrumbs, and comments.
coverImage, description,
keywords).
Modules\Media integrates elFinder (v2.1.67) with MIME allowlists,
trash handling, and optional WebP conversion via
claviska/simpleimage. elFinder's internal CSRF
validation is bypassed since CI4 Shield's session auth and
backendGuard filter already protect the connector.
Modules\Fileeditor offers in-project file
browsing/editing secured by realpath checks.
Modules\Logs implements a custom, highly-optimized
LogViewer library so administrators can securely
inspect writable/logs/ from
/backend/logs without shell access.
Modules\Backup provides database backup and restore
functionality, generating .zip archives in
writable/uploads/backups/.
mysqldump if available, falls back to a PHP-based
export.
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.
notifications table as a
global row (user_id = null) and addressed only through
target_type (broadcast |
user | group) and
target_value. Sending to a 50-member group still writes
exactly one row — there is no fan-out, and
toGroup() / toRole() return
1/0 rather than a recipient count.
notification_reads holds one row per
(notification_id, user_id) pair (UNIQUE), with CASCADE
foreign keys, so deleting a user or purging a notification clears the
matching read rows. "Unread" is an anti-join:
notifications LEFT JOIN notification_reads r ON
r.notification_id = n.id AND r.user_id = X WHERE r.id IS NULL,
cached for 60s under notif_unread_{userId}.
notification_preferences stores one row per
(user_id, type, channel). Because the notification row
is global there is no per-recipient row to skip at send time, so a
mute is applied on the read path.
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.
unreadCount,
listFor, markAllRead,
isRelevant — go through it.
applyRelevance() instead of re-implementing any layer in
a controller, a view, or a channel.
toUser() and toGroup() directives;
TargetResolver deduplicates identical ones and each
survivor becomes one global row. A user↔group overlap is
narrowed at send time — toUser(5) together with
toGroup('admin') writes both rows, but user 5 lands in
the group row's exclusion list, so the notification is seen exactly
once. The narrowing costs at most one extra bounded query
(Notifier::groupMembersAmong()) and never materializes
full group membership. A group∩group overlap is deliberately
not collapsed.
NotificationBuilder::exceptUser(int|array) removes ids
from every row of the dispatch, broadcast() included.
Storage is notifications.exclude_users
(TEXT NULL), a sentinel-wrapped CSV — always
comma-wrapped (,5,12,), NULL when empty —
so the read-side NOT LIKE '%,1,%' cannot collide with
,12,. JSON was avoided for MySQL/MariaDB portability.
NotificationMessage keeps provenance apart
(explicitExcludeUsers,
derivedExcludeUsers, and their union
excludeUsers). On an unmigrated schema
InAppChannel refuses a row carrying an
explicit exclusion
(skipped('exclusion-unsupported'), logged at
critical), because that exclusion is a guarantee; a row
whose exclusion is only derived is written anyway
(fail-open, logged at warning), since its worst case is
a duplicate delivery rather than a leak. The
EXCLUDE_USERS_MAX ceiling (500 ids) ignores provenance,
and the list is never truncated — the project runs
strictOn = false, so an overflowing TEXT
value would be cut silently and the broken sentinel would fail open.
applyRelevance() LEFT JOINs
notification_preferences and anti-joins it
(p.id IS NULL). A preference type is an
exact type or a prefix — audit mutes
audit and audit.login through
n.type LIKE CONCAT(p.type, '.%'), but never
auditor.x. critical notifications
cannot be muted, and
n.severity <> 'critical' sits on the join's
ON side, not in WHERE, so a surviving
row matches zero preference rows and neither
listFor() nor unreadCount() can be
duplicated or inflated.
SchemaGuard memoises per request — keyed by database
name plus table prefix, so a second DB group cannot inherit the
answer — whether notifications.exclude_users,
notification_preferences, and
notifications.created_by exist. When they do not, the
corresponding SQL is simply not emitted, so a module folder dropped
in before php spark migrate --all never fatals.
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.php —
Notifier::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.
ci4ms.audit
listener in app/Config/Events.php, which fires only for
warning-severity audit events and dispatches to the
group named by NotificationsConfig::$auditTargetGroup
(default superadmin). It is null-safe
(service('notifier')?->…) and wrapped in try/catch,
so a notifier failure can never break the audited request.
ComposerController +
Views/compose.php serve a "Send notification" screen at
backend/notifications/compose. Both audience pickers are
server-fed and server-revalidated — group names against
config('AuthGroups')->groups, user ids against the
users table in a single whereIn query —
and Notifier::scopeAddressableUsers() is the single
filter source that drops soft-deleted and banned
accounts from the picker, the validation, and the count alike.
TARGETS_MAX (200) caps users + groups per publication,
checked before the existence query and fail-closed: over the cap the
publication is refused, never trimmed. Sender identity is always
auth()->id(), there is no mass assignment, the
notification type is stamped server-side as the fixed
slug announcement (so a sender cannot mint a fresh slug
per send and step around existing mutes), and delivery goes
exclusively through the service('notifier') builder with
no direct INSERT. The screen's link field is narrowed to
a site-relative path, because an unmutable critical
notification addressed to superadmins reads as coming from "the
system" and an external link would carry that borrowed trust into a
phishing page.
Notifier::recipientCount() derives the audience size
from the publication definition alone — it never touches the
notifications table, makes no relevance decision, and
its query budget is flat in the input size. Its route is bound to the
send permission rather than read: the
number it returns is a membership/existence oracle, while
read is in practice held by every backend user so the
bell works.
notifications.created_by records who produced a row (not
a recipient), written only from auth()->id() and
NULL for programmatic/audit output. It carries no
foreign key on purpose — a CASCADE would delete an account's
notifications with the account and a SET NULL would erase the trail.
Its schema guard is fail-open: on an unmigrated schema the row is
still written and only the trail is lost.
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.
RealtimeChannel emits only after
InAppChannel commits the durable row, and it is
best-effort: a Redis outage yields a skipped result,
never a throw.
INCR notif:sig:{channel} plus
EXPIRE to realtimeSignalTtl, default 300s).
{channel} is always Notifier::topicFor()
output — broadcast, user/{id},
group/{name} — derived server-side, so the signal store
is IDOR-safe by construction.
feed, which
reads the database. On disconnect the bell degrades to the 60s poll
and stays in sync across tabs over a BroadcastChannel.
GET backend/notifications/stream
(RealtimeController::stream) runs behind
backendGuard with role = read — the same
permission the read endpoints already use, so no new permission is
introduced. It closes the session write lock and the default DB
connection immediately (critical under the
FileHandler session driver, which would otherwise block
every other backend request from that user), reads the authorized
channels with a single Redis MGET about once a second,
and caps each connection at realtimeStreamTtl (default
30s, clamped to a 120s ceiling) so EventSource
reconnects cleanly.
stream() reserves a slot before opening the
stream. Slots live in a Redis sorted set per user,
notif:conn:{userId} (member = a server-generated random
connection id, score = expiry), reached only through
ConnectionRegistryInterface /
RedisConnectionRegistry. Acquiring one is a
single atomic Lua EVAL — prune expired
→ reject if ZCARD >= cap → ZADD →
EXPIRE — because a split check/insert lets concurrent
requests race past the limit (a measured 30-request burst against
cap = 5 admitted six times the cap). The effective cap
comes from the caller's Shield groups
($realtimeConnCapByGroup, highest match wins), falling
back to $realtimeConnCapDefault (6). A negative value
means unlimited; 0 is invalid, not unlimited, and falls
back fail-closed to CONN_CAP_FALLBACK (6).
429.
This is the deliberate opposite of the best-effort
RealtimeSignal: without Redis the signal store returns
0 for every channel, so the stream has nothing to
deliver and holding a worker for the full TTL would burn exactly the
resource the cap protects. The client falls back to polling, so there
is no loss of functionality.
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":
extension_dir carries the API number
(.../pecl/20240924 = PHP 8.4,
.../pecl/20250925 = PHP 8.5), and a
redis.so sitting in the wrong one is simply never
loaded.
pecl install redis builds
against whichever php-config happens to be first on the
PATH, which is easily not the version FPM runs.
429, and the bell quietly stays on
polling.
php alias:
/path/to/the/fpm/php -m | grep redis. A green test suite
is not evidence — the Redis-backed tests skip (they
do not fail) when the extension is missing from the CLI PHP.
auth_permissions_pages; until a route has a
record there it is fail-closed 403 for everyone,
superadmin included, because the superadmin bypass only applies once
the page record exists. Follow the scan with
php spark cache:clear.
notifications.notifcomposesend.create
deliberately rather than by ticking every box in the module: the bell
permissions have to be held by every backend user, whereas the send
permission is the right to broadcast to the whole installation.
X-Accel-Buffering: no header.
Size pm.max_children for the concurrent-admin count
times their connection cap, and keep PHP
max_execution_time / FPM
request_terminate_timeout above
realtimeStreamTtl.
php spark notifications:purge is the manual cleanup
path.
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.
GET /releases/latest — the release
tag_name is validated against
/^v?\d+(?:\.\d+){1,3}$/ before it is used anywhere.
manifest.json and manifest.json.sig are
fetched through that release's
assets[].browser_download_url.
ManifestVerifier checks the detached signature with
sodium_crypto_sign_verify_detached() against every
active key in the keyring.
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.
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.
active keyring entry.manifest.version equals both the requested version
and the release tag_name; and
manifest.repo equals the configured repository.
version_compare(manifest.version, app.version, '>') —
the updater cannot downgrade an installation. The
supported way back is the existing rollback() path.
removed while the signed manifest still
lists it is a contradiction and aborts
(removed_but_signed); an empty apply set aborts
(empty_apply_set).
.env is raised to the new version only
when every expected file was actually written.
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.
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 |
Modules\Settings\Config\UpdateKeys holds a set keyed by
key_id; each entry carries public_key
(base64), status (active |
revoked), added and fingerprint.
active key must verify the signature.revoked
key_id rejects the entire manifest,
even when another signature on it is valid and active — a revoked key
on a release is evidence the release passed through a compromised
signer, so no partial trust is extended.
key_id is silently ignored.
That is what makes dual signing work during a
rotation window: old installations verify through the old key, new
ones through the new key, nobody is stranded.
key_id within one carrier is rejected.
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.
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.
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.
manifest.json and
manifest.json.sig.
php spark ci4ms:release:verify --remote --tag
v<x.y.z.w> — verifies the published assets from the
outside, exactly as an installation would.
--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.
| 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.
Modules\Methods when adding new secured routes.php spark cache:clear) after updating
settings, menus, or permissions.
info.xml, screenshot.png).
php spark ci4ms:setup in CI pipelines instead of
chaining multiple spark commands.
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.
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.