Reference this guide whenever you onboard, ship features, or maintain CI4MS. It merges environment requirements, coding conventions, workflow tips, and deployment best practices into a single companion for the engineering team.
| Layer | Required | Notes |
|---|---|---|
| PHP | 8.2+ |
Enable intl, json,
mbstring, gd, curl,
openssl extensions. Matches
composer.json (8.2).
|
| Composer | 2.5+ | Used for all PHP dependencies. |
| Database | MySQL / MariaDB |
Any CodeIgniter-supported driver works. Configure via
.env.
|
| Web server | Apache / Nginx / php spark serve |
Production deploys should point to the public/ directory. |
| Docker | Docker Engine 24+ / Desktop | Optional but recommended for local development and CI. |
app/ Application code (controllers, config, libraries, filters)
modules/ Feature modules (Auth, Backend, Blog, etc.)
public/
index.php Front controller
be-assets/ Admin UI build artifacts (CSS/JS)
templates/ Front-end themes (default shipped)
media/ Media storage (ensure writable)
writable/ Cache, logs, temporary files (must be writable)
vendor/ Composer packages
.docker/ Dockerfile, Apache vhost, and php.ini
.github/workflows/ GitHub Actions CI pipeline
docs/ Developer documentation (this file and companions)
Key config files:
composer.json — PHP dependencies and scripts.app/Config/DefaultRoutes.php — Routes template; copy to
Routes.php on setup.
app/Config/Paths.php — Path constants including
$supportDirectory (required by CI4 4.4+).
app/Config/*.php — Framework configuration; many
classes consume cached settings populated at runtime.
.env — Environment overrides; generated from the
env template.
git clone <repo-url> ci4ms
cd ci4ms
composer install
cp env .env
Update: app.baseURL, database.default.*,
mail credentials, etc.
cp app/Config/DefaultRoutes.php app/Config/Routes.php
php spark ci4ms:setup
This single command runs all migrations, seeds default data (admin user, sample pages, settings), and prepares the application.
php spark serve
cp env .env
cp app/Config/DefaultRoutes.php app/Config/Routes.php
# Edit .env: set database.default.hostname=db
docker compose up -d --build
docker exec ci4ms_app composer install
docker exec ci4ms_app php spark ci4ms:setup
Refer to DOCKER_SETUP.md for full configuration details.
The project depends on CodeIgniter 4 and several packages:
codeigniter4/framework — Core framework.codeigniter4/shield — Auth and RBAC.ci4commonmodel — Database abstraction.ci4seopro — SEO, JSON-LD, feeds.sql2migration — CLI migration tooling.ext_module_generator — Module scaffolding.maxmind-db/reader — Local MMDB reader behind the
opt-in session geo lookup.
ext-redis — Declared under
suggest (optional); only needed for the
Redis-backed realtime notification stream.
composer install
composer update vendor/package
composer outdated
The admin panel and templates use static JS/CSS packages (Tagify,
Monaco, etc.). To keep the repository small and performant, we do
not use npm or node_modules by default. Hosted
statically in plugins/ and vendor/.
If you introduce a bundler (like Vite) in the future, be sure to
compile assets and exclude node_modules from version
control.
Modules\<Name>, app code under App\.
Modules\Backend\Controllers\BaseController; frontend
extends App\Controllers\BaseController.
Modules\Blog\Views\list
etc.).
app/Config/Filters discovers them dynamically.
modules/<Module>/Config.
modules/<Module>/Language/<locale>. 11
languages are currently supported.
composer test; add
linting tooling if desired.
Permissions are stored in auth_permissions_pages (CRUD
JSON flags) and auth_users_permissions (overrides). The
Modules\Methods\Controllers\Methods::moduleScan() command
inspects routes and helps keep permissions in sync.
Workflow for a new module:
php spark make:module <Name> (provided by
ext_module_generator).
modules/<Name>/Config/Routes.php with
role metadata.
Clear cached permissions with php spark cache:clear or
cache()->delete("{userId}_permissions").
settings table
and cached for 24h. Clear via
cache()->delete('settings').
settings key can
be warmed by whichever entry point hits it first while it is cold
(app/Config/Filters.php, the generated
app/Config/Routes.php, or
modules/Auth/Controllers/BaseController.php). Every
filler must use the same decode —
json_decode($value) gated on
json_last_error() === JSON_ERROR_NONE &&
(is_object($decoded) || is_array($decoded)) — so a cold cache
always warms to a consistent stdClass shape. Read nested
settings with object access
($settings->group->key); only JSON-array levels
stay PHP arrays and remain foreach-iterable. Never
(object) json_decode(...)-cast the top level: nested
levels stay arrays and the mixed shape fatals under PHP 8. Scalar
flags such as maintenanceMode and
convertWebp are read as raw strings, not through a
->scalar wrapper.
Modules\Settings\Libraries\CacheRegistry. Adding a new
clearable cache means adding an entry there (logical id, label key,
and its exact/glob operations) — the client only ever sends the
logical id.
menus_{locale} (per-locale); automatically
refreshed when editing via the Menu module.
settings.maintenanceMode; triggers redirect in
App\Filters\Ci4ms.
Modules\Media\Controllers\Media::elfinderConnection().
settings.allowedFiles.
claviska/simpleimage.
Media::WRITE_COMMANDS — one constant feeding both
the controller-level 403 gate (isWriteBlocked()) and
elFinder's disabled list — with
elfinderAccess() as the authoritative server-side
guarantee.
php to
text/x-php, so .phtml,
.php5, .pht and .phar fall
through to text/plain, which
settings.allowedFiles permits.
Media::DENIED_EXTENSIONS refuses them on the
write attribute, checking
every dot-separated segment so
shell.php.jpg is rejected as well. Reads are
untouched, so an administrator can still see and delete anything
already present.
volumeSecurityOptions():
uploadDeny => ['all'], allowlist from settings,
uploadOrder => ['deny','allow'] (must stay
deny-first) and uploadMaxSize 32M. Pinned by
tests/Modules/Media/MediaUploadGateTest.php.
public/media/ (ensure writable, include
.trash folder).
public/media/.htaccess blocks execution there — but
Apache only; nginx/Caddy/FrankenPHP ignore it and
must mirror the rules in the vhost.
realpath guards and a
$dangerousExtensions blacklist (.php, .phtml,
.phar, .htaccess, etc.) to prevent creating or modifying
executable files. Only $allowedExtensions (css, js,
html, txt, json, sql, md) can be read/edited.
Limit use to trusted roles.
public/templates/<theme>; ZIP uploads extract
to writable/tmp.
Database/Migrations/; run on activation.
info.xml,
screenshot.png (checked by backend filter).
mysqldump or PHP fallback.
writable/uploads/backups. Restore directly from the
backend or download archives.
INSERT, CREATE TABLE, DROP TABLE,
ALTER TABLE, SET, UPDATE, DELETE
are allowed). Dangerous commands (LOAD_FILE, INTO OUTFILE,
GRANT, CREATE USER, stored procedures) are blocked and
logged. Backup files must reside within WRITEPATH.
public/index.php bootstraps CodeIgniter; production
servers should expose only public/.
public/maintenance/ serves the maintenance splash when
enabled.
public/be-assets/ houses admin CSS/JS, images, plugins,
and package manifests.
public/media/ contains uploaded media (include in
backups).
public/templates/default/ is the bundled theme—use it
as a blueprint for custom themes.
tests/. Add module-specific tests
in tests/Modules/<Module>.
.github/workflows/docker-test.yaml) runs on every
push:
composer install.php spark ci4ms:setup.app/ and modules/.
There is no separate test database:
CommonModel hardcodes the default connection
group, so a test that exercises a real query-builder path runs against
your development database. Two patterns keep that non-destructive — use
whichever fits:
type value, a throwaway high
user id), assert counts as deltas so unrelated existing rows
cannot skew them, and delete exactly those rows in
tearDown(). Never mutate a pre-existing row.
CREATE TEMPORARY TABLE). When a test needs a
schema the development database does not have — a column or table
added by a migration that has not run there — do not
run the migration. Create a TEMPORARY table of the same
name on the same connection the production code uses: in
MySQL/MariaDB a temporary table masks a permanent one of the same
name for that connection only, so the real schema is never altered,
real rows are neither read nor written, and the shadow disappears
when the connection closes.
Rules that make the shadow safe:
tearDown() still drops it explicitly, always with the
TEMPORARY keyword — which makes the statement a no-op
against a permanent table.
CREATE TEMPORARY TABLES, skip
the test rather than weakening it.
Modules\Notifications\Libraries\SchemaGuard) must be
reset both when a shadow is created and when it is dropped;
otherwise a stale true leaks into later test classes and
they emit SQL for columns the real table does not have.
tests/_support/Notifications/ShadowSchemaTrait.php
implements the technique — including a pre-migration variant used to
assert fail-closed behaviour — and
assertPermanentSchemaIntact() proves the shadow never
leaked into real DDL.
CI_ENVIRONMENT=development.
writable/logs/; ensure permissions allow
writing or inspect them via the backend viewer at
/backend/logs.
php spark cache:clear or delete
contents of writable/cache/.
ci_migrations table for batch sync.
docker compose logs app to inspect
container output.
CI4MS implements modern security practices to protect the application and user data:
public/be-assets/js/ci4ms.js automatically reads the CSRF token from the meta tag and injects it into all AJAX requests. Do not disable CSRF per module unless absolutely necessary.CustomRules::getClean() to scrub HTML through HTMLPurifier. Dangerous schemes like data: and properties like CSS.Trusted are disabled by default..php, .phtml, .phar, etc.) to prevent Remote Code Execution (RCE). Operations are restricted strictly to safe paths using realpath() boundary validations.
Modules\Notifications is a drop-in HMVC module that
renders a bell dropdown in the admin header and a list page at
backend/notifications. Producing modules never write to
the database directly: every notification flows through one service,
and every read passes through one relevance chokepoint.
Resolve the shared service with service('notifier') and
use the fluent builder. Nothing else is required — the in-app channel
writes the row and invalidates the unread-badge caches.
// Notify a single user
service('notifier')
->notify('comment.new') // machine-readable event type
->title('New comment awaiting moderation')
->body('A visitor commented on "Hello World".')
->url('/backend/blog/comments') // site-relative or http(s) only
->severity('info') // info | warning | critical
->toUser(5)
->dispatch();
// Notify every member of a Shield group (one row; membership resolved on read)
service('notifier')
->notify('update.available')
->title('A new platform update is available')
->severity('warning')
->toGroup('superadmin')
->dispatch();
toUser() / toGroup() directives may
be combined; each survivor becomes one global row and identical
directives are deduplicated. broadcast() overrides every
other target.
exceptUser(int|array) excludes ids from every row of the
dispatch, broadcast() included, and wins over
toUser().
createdBy(?int) records who produced the
notification — pass a server-derived id such as
auth()->id(). It is an audit trail, not a target, so
it changes no delivery decision.
via(string ...$channels) selects channels; the default
is ['inapp', 'realtime'].
NotificationMessage
DTO — do not repeat strip_tags or URL validation in your
caller. Lengths are clamped in characters
(mb_substr) to match the max_length
validation rule.
dispatch() returns a ChannelResult[]. Do
not read that array as "any result is ok,
therefore it was sent": under Model B only a channel that persists a
row has delivered anything. Wrap the results in
DispatchOutcome instead.
$outcome = DispatchOutcome::fromResults(
service('notifier')->notify('announcement')
->title('Scheduled maintenance')
->toGroup('superadmin')
->dispatch()
);
$outcome->attempted(); // durable rows attempted
$outcome->stored(); // durable rows actually written
$outcome->isComplete(); // every attempted row stored (and at least one was)
$outcome->isPartial(); // some stored, some refused
$outcome->storedNothing(); // nothing stored, incl. "no durable channel ran"
$outcome->refusals(); // ['exclusion-unsupported', 'insert-failed', ...]
A partial result must never be reported to an operator
as success. The back-compat wrappers
Notifier::toUser() / toRole() and
php spark notifications:test still use the old "any
channel ok" predicate — treat their return value as "a delivery was
attempted", never as "a row exists".
applyRelevance() rule
Notifier::applyRelevance() is the single relevance and
IDOR chokepoint. It layers three filters in order — target, row-level
exclusion, per-user preference — and all four existing read paths
(unreadCount, listFor,
markAllRead, isRelevant) go through it.
If you add a new read path, it must call
applyRelevance(). Do not re-implement any of the
three layers in a controller, a view, or a channel:
,5,12,); a LIKE written by hand without
the wrapping commas matches the wrong ids.
n.severity <> 'critical' on the join's
ON side; moving it into WHERE would
make critical notifications mutable and would let
countAllResults() be inflated by several matching mute
rows.
The same rule holds for the transport:
Notifier::topicsFor() is the mirror of
applyRelevance() used by the SSE stream, so realtime
channels are derived from the session and never from the client.
A channel is any class implementing
Libraries\Channels\ChannelInterface:
public function send(NotificationMessage $message): ChannelResult;
If your channel persists the notification — an
ok result means a record will still be there when the user
reconnects — implement
Libraries\Channels\DurableChannelInterface instead. It
extends ChannelInterface and adds no methods; that marker
is what DispatchOutcome counts. A channel that forgets it
is treated as transient, so the mistake under-reports delivery rather
than over-reporting it.
Register the channel from your own module the same way
Filters.php discovers $csrfExcept — declare
it on Config/{Name}Config.php:
public array $notificationChannels = [
'slack' => \Modules\MyModule\Libraries\Channels\SlackChannel::class,
];
Notifier::resolveChannels() scans every
modules/*/Config/{Module}Config.php, and a
module-declared slug overrides the base map. Opt a notification into it
with ->via('inapp', 'slack').
ext-redis version trap
Realtime SSE is optional and disabled by default
(notificationsconfig.realtimeEnabled; note the env prefix
is the lowercased short class name, so
notificationsconfig. and not
notifications.). It reuses the Redis connection from
Config\Cache::$redis — there is no separate connection
setting — and needs ext-redis (phpredis).
phpredis must be installed for the PHP version that actually serves the site, not merely "somewhere on the machine". This costs more debugging time than anything else in the module:
extension_dir carries the API number
(.../pecl/20240924 = PHP 8.4,
.../pecl/20250925 = PHP 8.5), and a
redis.so in the wrong directory is never loaded.
pecl install redis builds
against whichever php-config is first on the
PATH, which is easily not the one FPM runs.
RealtimeSignal becomes a no-op, the connection registry
cannot reserve a slot, the stream answers 429
(fail-closed), and the bell quietly stays on 60s polling.
php alias:
/path/to/the/fpm/php -m | grep redis. The CLI matters
too — the Redis-backed tests skip rather than fail when the
extension is absent, so a green suite is not evidence that
realtime works in the browser.
Permission strings are derived from the route's as name,
not from the controller class:
Modules\Methods\Libraries\ModuleScanner stores
pagename = '{Module}.{route name}' and
Ci4MsAuthFilter then checks
strtolower(pagename) . '.{action}'. Registration is not a
spark command — run Backend → Methods / Modules →
Module Scan, then
php spark cache:clear.
Until a route has a record in auth_permissions_pages it is
fail-closed 403 for everyone, superadmin
included — the superadmin bypass only applies once the page
record exists.
Login sessions can be enriched with an approximate city / region /
country resolved entirely against a local DB-IP City
Lite database (MMDB). The visitor's IP address never leaves the server
and no third-party request is made — this replaced an inline
ip-api.com HTTP call.
Modules\Auth\Libraries\GeoLocator reads the MMDB file
through maxmind-db/reader. It returns
null on any failure, so a missing or corrupt database
can never break login — a lesson from the crash it replaced, where a
failed fetch returned false and
json_decode(false) raised an uncaught
TypeError under declare(strict_types=1)
(the @ operator does not suppress a
TypeError).
Modules\Auth\Models\UserSessionModel::recordLogin().
Auth.geoLookupEnabled setting
(config default false), exposed as the Settings →
"Session Location Tracking" toggle (route
saveGeoLookup; AJAX + role=update + CSRF +
in_list validation) and as an opt-in checkbox in the web
installer. Behaviour change on upgrade: existing
installations stop collecting geo data until an administrator enables
the setting and downloads the database.
writable/geoip/ and is downloaded or refreshed with
php spark ci4ms:geoip-update — the command gunzips,
verifies with a test lookup, and atomically renames into place. It is
flock-guarded against concurrent runs and is designed to
be scheduled as a monthly cron job.
Schema and ledger can drift apart — a partial dump restore, a ledger
reset, or a manual ALTER leaves a column present while its
row in the migrations table is missing. The runner then
sees the migration as pending, re-runs it, and MySQL rejects it
(error 1060, Duplicate column name). Because the ledger row
can never be written, every subsequent run aborts at
the same point and every later migration in the batch is blocked.
Rules for a schema-altering migration:
addColumn() with fieldExists() and
return early when the column is already there, so the run records the
migration and moves on.
tableExists(), or use CI4's
own createTable($table, true)
(CREATE TABLE IF NOT EXISTS).
after clause only when the anchor column
actually exists — MySQL rejects the whole ALTER for an
unknown anchor.
warning
and create the object without the constraint rather than failing the
run.
down() non-destructive for objects
that carry data: document the reverse ALTER /
DROP as a manual step in a comment instead of executing
it.
For code that must run before its migration has been applied,
pair the guard with a runtime capability check. The Notifications
module's SchemaGuard memoises per request — keyed by
database name plus table prefix, so a second DB group cannot inherit
the answer — whether an optional column or table exists, and the
dependent SQL is simply not emitted when it does not. Long-running
workers must call SchemaGuard::reset() after a migration.
CI_ENVIRONMENT=production.app.baseURL.public/; restrict other
directories.
php spark migrate --all, relevant
seeders).
App.php::$proxyIPs with trusted IP ranges (see commented
examples in the file) so $request->getIPAddress() returns
real client IPs.
.env before major
upgrades.
ext-redis for the PHP
version that serves the site, point Config\Cache::$redis
at a reachable Redis, set the notificationsconfig.* env
keys (realtimeEnabled = true, plus the optional
realtimeStreamTtl / realtimeSignalTtl /
realtimeConnCapDefault /
realtimeConnCapByGroup.superadmin), register the stream
route as a permission
(notifications.realtimecontroller.read — the same
permission the read endpoints use) via the Methods module scan, then
php spark cache:clear. Because each open stream holds a
short-lived php-fpm worker for up to realtimeStreamTtl
seconds (120s ceiling), size pm.max_children for
concurrent admins times their connection cap and keep PHP
max_execution_time / FPM
request_terminate_timeout above
realtimeStreamTtl. Left disabled (the default), the bell
keeps its 60s polling behaviour.
php spark migrate --all has added
notifications.exclude_users /
notifications.created_by and created
notification_preferences, run the Methods
Module Scan so the preference and composer routes
land in auth_permissions_pages, grant them deliberately
(the send permission is the right to broadcast to the whole
installation), then php spark cache:clear. Until the
migrations run the module still delivers ordinary notifications, but
a dispatch carrying an explicit
exceptUser() exclusion is refused rather than delivered
to an excluded user.
feature/blog-scheduling).
[Blog] Add scheduling support).
CHANGELOG.md updates.
CHANGELOG.md (Keep a Changelog format) before merging.Update this handbook whenever the stack or workflows evolve so the team always has a current source of truth.
CI4MS is engineered for extensibility. Keep this page bookmarked, share it with the team, and iterate responsibly.