CI4MS Engineering

Developer Handbook

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.

1. System Requirements

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.

2. Repository Layout Highlights

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:

3. Getting Started

3.1 Standard (local PHP)

  1. Clone & install
    git clone <repo-url> ci4ms
    cd ci4ms
    composer install
  2. Environment
    cp env .env

    Update: app.baseURL, database.default.*, mail credentials, etc.

  3. Prepare routes
    cp app/Config/DefaultRoutes.php app/Config/Routes.php
  4. One-command setup
    php spark ci4ms:setup

    This single command runs all migrations, seeds default data (admin user, sample pages, settings), and prepares the application.

  5. Serve
    php spark serve

3.2 Docker

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.

4. Dependency Management

Composer packages

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

Frontend Assets

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.

5. Coding Guidelines

6. Modules & Permissions

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:

  1. Scaffold with php spark make:module <Name> (provided by ext_module_generator).
  2. Define routes in modules/<Name>/Config/Routes.php with role metadata.
  3. Implement controllers, models, views.
  4. Update permissions (run module scan or insert records manually).
  5. Create migrations/seeds for new tables if required.

Clear cached permissions with php spark cache:clear or cache()->delete("{userId}_permissions").

7. Configuration & Settings Cache

8. Media, File, & Theme Handling

Media Manager

  • elFinder config in Modules\Media\Controllers\Media::elfinderConnection().
  • Allowed MIME types from settings.allowedFiles.
  • Optional WebP conversion via claviska/simpleimage.
  • Who may write is gated by 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.
  • What may be written is a separate gate. The MIME allowlist alone is porous: elFinder maps only 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.
  • Upload options live in volumeSecurityOptions(): uploadDeny => ['all'], allowlist from settings, uploadOrder => ['deny','allow'] (must stay deny-first) and uploadMaxSize 32M. Pinned by tests/Modules/Media/MediaUploadGateTest.php.
  • Storage: 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.

File Editor & Themes

  • Fileeditor: Uses 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.
  • Themes: Located under public/templates/<theme>; ZIP uploads extract to writable/tmp.
  • Migrations: Themes can ship migrations inside Database/Migrations/; run on activation.
  • Boilerplate: Generate a starter boilerplate ZIP directly from the Theme Manager panel.
  • Required theme files: info.xml, screenshot.png (checked by backend filter).

Backup & Restore

  • Backup: Database dumps via mysqldump or PHP fallback.
  • Restore: Restore from local ZIPs or server archives.
  • Storage: Backups saved in writable/uploads/backups. Restore directly from the backend or download archives.
  • Security: SQL restore enforces a statement whitelist (only 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.

9. Public Assets & Front Controller

10. Testing & QA

Testing against the live schema without touching it

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:

Rules that make the shadow safe:

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.

11. Debugging Tips

12. Security Architecture

CI4MS implements modern security practices to protect the application and user data:

13. Notifications Module (Developer Guide)

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.

13.1 Sending a notification

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();

13.2 Was it actually delivered?

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".

13.3 The 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:

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.

13.4 Adding a channel

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').

13.5 Realtime and the 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:

13.6 Registering the permissions

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.

14. Session Geo Lookup (Local, Opt-In)

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.

15. Migrations & Schema Guards

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:

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.

16. Deployment Checklist

  1. Switch to CI_ENVIRONMENT=production.
  2. Set the public app.baseURL.
  3. Point the web server root to public/; restrict other directories.
  4. Run migrations/seeds (php spark migrate --all, relevant seeders).
  5. Optional cache warm-up via scripted requests.
  6. Proxy IPs: If behind Cloudflare or Nginx, configure App.php::$proxyIPs with trusted IP ranges (see commented examples in the file) so $request->getIPAddress() returns real client IPs.
  7. Disable the debug toolbar in production.
  8. Set secure permissions (775/664 depending on user/group).
  9. Back up uploads, database, and .env before major upgrades.
  10. Realtime notifications (optional). The Redis-backed SSE bell is self-contained — no external hub, JWT, or nginx config change. To enable it: install 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.
  11. Notification upgrade steps. After 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.

17. Contribution Workflow

18. Further Reading & Resources

Update this handbook whenever the stack or workflows evolve so the team always has a current source of truth.

Ship with Confidence

CI4MS is engineered for extensibility. Keep this page bookmarked, share it with the team, and iterate responsibly.