{"id":172,"date":"2026-10-16T13:25:48","date_gmt":"2026-10-16T07:55:48","guid":{"rendered":"https:\/\/rainverse.com\/insights\/?p=172"},"modified":"2026-10-16T13:25:49","modified_gmt":"2026-10-16T07:55:49","slug":"codeigniter-3-to-4-hmvc-migration","status":"publish","type":"post","link":"https:\/\/rainverse.com\/insights\/codeigniter-3-to-4-hmvc-migration\/","title":{"rendered":"The ERP That Outlived Its Framework: Migrating from CodeIgniter 3 + HMVC to CodeIgniter 4"},"content":{"rendered":"\n<p class=\"text-justify\"><strong>Some software doesn&#8217;t get replaced \u2014 it just keeps working, year after year, until the ground underneath it quietly disappears.<\/strong><\/p>\n<p class=\"text-justify\">That&#8217;s the situation with a certain class of business system: a custom ERP built in the mid-2010s on <strong>CodeIgniter 3 with the MX HMVC extension<\/strong>, dutifully recording invoices, stock movements, and payments. It does its job. The trouble is everything <em>around<\/em> it has moved on \u2014 the framework, the language, and the extension that made its architecture feel modern at the time.<\/p>\n<p class=\"text-justify\">This is a practical account of what it takes to move such a system to <strong>CodeIgniter 4 on PHP 8.5<\/strong> \u2014 what genuinely improves, what the version numbers actually mean, and where the real work hides.<\/p>\n<hr>\n<h3>Why CI3 + HMVC systems age badly<\/h3>\n<p class=\"text-justify\">CodeIgniter 3 was a fine framework for its era. But its design assumptions belong to a different decade of PHP:<\/p>\n<ul>\n  <li><strong>The global singleton loader.<\/strong> Everything arrives through <code>$this-&gt;load-&gt;model(...)<\/code>, <code>$this-&gt;load-&gt;library(...)<\/code>. Dependencies are pulled, never injected \u2014 so classes are hard to test in isolation and hard to swap.<\/li>\n  <li><strong>No native namespacing.<\/strong> CI3 predates PSR-4. Organising code meant HMVC&#8217;s module folders, not namespaces.<\/li>\n  <li><strong>HMVC was the workaround.<\/strong> The <strong>MX HMVC extension<\/strong> let you call one controller from another \u2014 a &#8220;triangular&#8221; request \u2014 so teams could build widget-like modules. It solved a real problem, but via cross-controller coupling rather than a service layer.<\/li>\n  <li><strong>The framework is no longer maintained.<\/strong> CodeIgniter 4 is the maintained line. The original CI3 branch is no longer developed, and the community <strong>MX HMVC extension has been abandoned<\/strong> \u2014 its repository is gone.<\/li>\n  <li><strong>PHP 8.0 has been end-of-life since 26 November 2023.<\/strong> Running production financial data on an EOL interpreter means no security patches.<\/li>\n<\/ul>\n<p class=\"text-justify\">None of that is a crisis on a Tuesday afternoon. It becomes one the first time a security advisory lands, a hosting provider drops an old PHP build, or a client asks for a feature that the current architecture makes expensive.<\/p>\n<hr>\n<h3>What CodeIgniter 4 genuinely changes<\/h3>\n<p class=\"text-justify\">This is where the marketing and the reality diverge, so let&#8217;s be precise.<\/p>\n<h4>PSR-4 modules replace HMVC<\/h4>\n<p class=\"text-justify\">CI4 is namespaced and Composer-native. The module folder hack becomes a first-class namespace mapped in <code>app\/Config\/Autoload.php<\/code>:<\/p>\n<pre><code>public $psr4 = [\n    APP_NAMESPACE =&gt; APPPATH,\n    'Modules'     =&gt; ROOTPATH . 'modules',\n];<\/code><\/pre>\n<p class=\"text-justify\"><strong>What it buys you:<\/strong> real encapsulation. A module is a namespace with its own models and controllers, not a folder that controllers reach into.<\/p>\n<h4>A service registry \u2014 not an autowiring container<\/h4>\n<p class=\"text-justify\">CI4 adds <strong>Services<\/strong>: a central registry that <em>creates and shares<\/em> class instances (<a href=\"https:\/\/codeigniter.com\/user_guide\/concepts\/services.html\" target=\"_blank\" rel=\"noopener\" style=\"text-decoration: underline;\">CI4 Services docs<\/a>). It is best described as <strong>a factory<\/strong>, not a Laravel-style dependency-injection container.<\/p>\n<p class=\"text-justify\">This distinction matters, because a common mistake is to assume CI4 will inject typed constructor dependencies automatically. <strong>It won&#8217;t.<\/strong> This does not work:<\/p>\n<pre><code>\/\/ \u2717 CI4 does not auto-resolve constructor dependencies\npublic function __construct(\n    private InvoiceModel $invoices,\n    private PdfGenerator $pdf,\n) {}<\/code><\/pre>\n<p class=\"text-justify\">What <em>does<\/em> work is resolving explicitly \u2014 by configuration, through the registry:<\/p>\n<pre><code>\/\/ Config\\Services \u2014 define the factory once\npublic static function invoices($getShared = true)\n{\n    return $getShared\n        ? static::getSharedInstance('invoices')\n        : new \\App\\Models\\InvoiceModel();\n}\n\n\/\/ In a controller\npublic function __construct()\n{\n    $this-&gt;invoices = service('invoices');   \/\/ shared instance\n    $this-&gt;pdf      = service('pdf');\n}<\/code><\/pre>\n<p class=\"text-justify\">The win is real \u2014 dependencies become swappable and testable \u2014 but it&#8217;s <strong>configuration-driven<\/strong>, and worth knowing before you plan the work.<\/p>\n<h4>Models become richer<\/h4>\n<p class=\"text-justify\">CI4 models return <strong>Entity<\/strong> objects (opt-in via <code>$returnType<\/code>), and support <strong>soft deletes<\/strong> and <strong>automatic timestamps<\/strong> as first-class features. For an ERP, that&#8217;s meaningful: an invoice becomes an object with behaviour instead of an array you hope has the right keys.<\/p>\n<h4>Filters replace hooks \u2014 and they compose<\/h4>\n<p class=\"text-justify\">The hooks system becomes <strong>Filters<\/strong>, applied per-route-group with arguments:<\/p>\n<pre><code>$routes-&gt;group('api', ['filter' =&gt; ['auth', 'role:admin', 'audit']], function ($routes) {\n    \/\/ ...\n});<\/code><\/pre>\n<p class=\"text-justify\">For financial systems, being able to declare <strong>auth \u2192 role \u2192 audit trail<\/strong> as a composable chain on a route group \u2014 rather than sprinkling hook logic \u2014 is a genuine structural improvement.<\/p>\n<h4>Tooling comes with the framework<\/h4>\n<p class=\"text-justify\">CI4 ships with the <strong><code>spark<\/code> CLI<\/strong> (migrations, seeders, generators) and <strong>PHPUnit integration<\/strong>. The days of hand-rolled test scaffolding and ad-hoc schema scripts end.<\/p>\n<hr>\n<h3>The PHP 8.5 features that actually matter here<\/h3>\n<p class=\"text-justify\">Modern PHP does more for a financial system than the framework does. But be careful with version claims \u2014 several features people credit to 8.5 are much older.<\/p>\n<p class=\"text-justify\"><strong>Genuinely new in PHP 8.5<\/strong> (released 20 November 2025 \u2014 <a href=\"https:\/\/www.php.net\/releases\/8.5\/en.php\" target=\"_blank\" rel=\"noopener\" style=\"text-decoration: underline;\">php.net<\/a>):<\/p>\n<ul>\n  <li><strong>The pipe operator (<code>|&gt;<\/code>)<\/strong> \u2014 chain transformations left-to-right instead of nesting calls:<\/li>\n<\/ul>\n<pre><code>  $total = $lineItems\n      |&gt; array_filter(..., fn ($i) =&gt; ! $i-&gt;isVoid())\n      |&gt; array_map(..., fn ($i) =&gt; $i-&gt;subtotal())\n      |&gt; array_sum(...);<\/code><\/pre>\n<ul>\n  <li><strong><code>clone with<\/code><\/strong> \u2014 copy an object while overriding properties, which makes immutable value objects practical:<\/li>\n<\/ul>\n<pre><code>  $corrected = clone($invoice, ['status' =&gt; 'reissued']);<\/code><\/pre>\n<ul>\n  <li><strong><code>#[\\NoDiscard]<\/code><\/strong> \u2014 mark a function whose return value must be consumed, so a silently-ignored result (the classic source of accounting bugs) becomes a warning:<\/li>\n<\/ul>\n<pre><code>  #[\\NoDiscard]\n  function postLedgerEntry(Entry $entry): LedgerResult { \/* ... *\/ }<\/code><\/pre>\n<p class=\"text-justify\"><strong>Worth knowing:<\/strong> two features often credited to 8.5 arrived earlier \u2014 <strong>constructor property promotion is PHP 8.0<\/strong>, and <strong>readonly properties are PHP 8.1<\/strong>. They still matter (immutable DTOs for monetary values are a good pattern), but if you cite them as 8.5 features, a PHP developer will notice.<\/p>\n<h4>Version requirements you should plan around<\/h4>\n<ul>\n  <li><strong>PHP for CI4<\/strong> \u2014 <strong>8.2 or newer<\/strong><\/li>\n  <li><strong>PHP 8.5<\/strong> \u2014 Requires <strong>CodeIgniter 4.7.0 or later<\/strong><\/li>\n  <li><strong>PHP 8.4<\/strong> \u2014 Requires CodeIgniter 4.6.0 or later<\/li>\n  <li><strong>Official CI3 ceiling<\/strong> \u2014 <strong>PHP 8.1<\/strong> (a community fork extends CI3 to 8.2\u20138.6)<\/li>\n  <li><strong>PHP EOL dates<\/strong> \u2014 8.0 \u2192 26 Nov 2023 \u00b7 8.1 \u2192 31 Dec 2025 \u00b7 <strong>8.2 \u2192 31 Dec 2026<\/strong><\/li>\n<\/ul>\n<p class=\"text-justify\"><em>Sources: <a href=\"https:\/\/codeigniter.com\/user_guide\/intro\/requirements.html\" target=\"_blank\" rel=\"noopener\" style=\"text-decoration: underline;\">CI4 server requirements<\/a>; <a href=\"https:\/\/github.com\/pocketarc\/codeigniter\" target=\"_blank\" rel=\"noopener\" style=\"text-decoration: underline;\">pocketarc\/codeigniter fork<\/a>.<\/em><\/p>\n<p class=\"text-justify\">That &#8220;official CI3 ceiling = PHP 8.1&#8221; line is the one that forces the decision: <strong>you cannot simply upgrade PHP and keep CI3.<\/strong> You either move to CI4, or adopt a maintenance fork and accept that you&#8217;re on a patched legacy branch.<\/p>\n<hr>\n<h3>What the migration actually costs<\/h3>\n<p class=\"text-justify\">Honesty section. CI4 is a <strong>rewrite of the application layer<\/strong>, not a version bump \u2014 there is no automated CI3\u2192CI4 converter.<\/p>\n<ul>\n  <li><strong>Syntax overhaul.<\/strong> <code>$this-&gt;input-&gt;post()<\/code> becomes <code>$this-&gt;request-&gt;getPost()<\/code>. <code>$this-&gt;load-&gt;<\/code> disappears in favour of helpers and services (<code>view()<\/code>, <code>model()<\/code>, <code>service()<\/code>, <code>db_connect()<\/code>).<\/li>\n  <li><strong>Sessions and validation<\/strong> are rewritten conceptually, not just re-signed. Form validation in particular moves to a rule-set model that most CI3 codebases touch everywhere.<\/li>\n  <li><strong>HMVC &#8220;triangular&#8221; calls must be untangled.<\/strong> Every place a controller invoked another controller becomes a <strong>service call<\/strong> \u2014 which is the point, but it&#8217;s the largest structural chunk of work.<\/li>\n  <li><strong>Financial integrity needs an explicit test net.<\/strong> For an ERP, &#8220;the page loads&#8221; is not verification. Ledger totals, tax calculations, and stock reconciliation need regression tests <em>before<\/em> the migration starts, so you can prove parity <em>after<\/em>.<\/li>\n  <li><strong>Realistic effort: multi-month<\/strong> for a non-trivial ERP, dominated by the HMVC untangling and validation rewrite \u2014 not by the framework itself.<\/li>\n<\/ul>\n<hr>\n<h3>A practical checklist<\/h3>\n<ol>\n  <li><strong>Freeze the behaviour.<\/strong> Capture current outputs (invoices, ledgers, reports) as golden fixtures. Migration without parity tests is a gamble.<\/li>\n  <li><strong>Inventory the HTTP surface.<\/strong> Every route, every <code>$this-&gt;input-&gt;<\/code> call, every session use.<\/li>\n  <li><strong>Map HMVC call graph.<\/strong> Each cross-controller call becomes a service; draw it before you cut it.<\/li>\n  <li><strong>Define services first.<\/strong> Build <code>Config\\Services<\/code> factories for models, PDF, mail, and anything injectable.<\/li>\n  <li><strong>Move to namespaces and PSR-4<\/strong> module by module, keeping the old code runnable until parity is proven.<\/li>\n  <li><strong>Convert filters<\/strong> (auth\/role\/audit) as route-group chains, not scattered hooks.<\/li>\n  <li><strong>Adopt CI4 validation<\/strong> per form, with tests.<\/li>\n  <li><strong>Pin versions:<\/strong> CI \u2265 4.7 for PHP 8.5; confirm your hosting supports the target runtime.<\/li>\n  <li><strong>Run parity tests<\/strong> against the frozen fixtures before cutover.<\/li>\n<\/ol>\n<hr>\n<h3>The decision rule<\/h3>\n<p class=\"text-justify\">Don&#8217;t migrate because the version number looks old. <strong>Migrate when the EOL risk plus the cost of building on a dead framework exceeds the cost of the move<\/strong> \u2014 and for a system holding financial records on an end-of-life interpreter, that threshold has usually already passed.<\/p>\n<p class=\"text-justify\">The upside is straightforward: supported tooling, a testable service layer instead of cross-controller spaghetti, modern PHP features that reduce accounting-style bugs, and an architecture you can actually extend for the next decade.<\/p>\n<p class=\"text-justify\"><em>Information as of October 2026; framework and language versions change \u2014 verify against the linked documentation before planning work. Nothing here is legal or financial advice.<\/em><\/p>\n<hr>\n<h3>Sources<\/h3>\n<ul>\n  <li>PHP \u2014 8.5 release announcement: <a href=\"https:\/\/www.php.net\/releases\/8.5\/en.php\" target=\"_blank\" rel=\"noopener\" style=\"text-decoration: underline;\">php.net\/releases\/8.5<\/a><\/li>\n  <li>PHP \u2014 8.5 new features (NoDiscard, clone with, pipe): <a href=\"https:\/\/www.php.net\/manual\/en\/migration85.new-features.php\" target=\"_blank\" rel=\"noopener\" style=\"text-decoration: underline;\">php.net\/manual\/migration85<\/a><\/li>\n  <li>PHP \u2014 8.0 release (constructor property promotion): <a href=\"https:\/\/www.php.net\/releases\/8.0\/en.php\" target=\"_blank\" rel=\"noopener\" style=\"text-decoration: underline;\">php.net\/releases\/8.0<\/a><\/li>\n  <li>PHP \u2014 8.1 release (readonly properties): <a href=\"https:\/\/www.php.net\/releases\/8.1\/en.php\" target=\"_blank\" rel=\"noopener\" style=\"text-decoration: underline;\">php.net\/releases\/8.1<\/a><\/li>\n  <li>PHP.Watch \u2014 readonly properties (8.1): <a href=\"https:\/\/php.watch\/versions\/8.1\/readonly\" target=\"_blank\" rel=\"noopener\" style=\"text-decoration: underline;\">php.watch<\/a><\/li>\n  <li>CodeIgniter 4 \u2014 server requirements (PHP 8.2+; PHP 8.5 needs CI 4.7.0+; EOL dates): <a href=\"https:\/\/codeigniter.com\/user_guide\/intro\/requirements.html\" target=\"_blank\" rel=\"noopener\" style=\"text-decoration: underline;\">codeigniter.com\/user_guide\/intro\/requirements<\/a><\/li>\n  <li>CodeIgniter 4 \u2014 Services (factory\/registry): <a href=\"https:\/\/codeigniter.com\/user_guide\/concepts\/services.html\" target=\"_blank\" rel=\"noopener\" style=\"text-decoration: underline;\">codeigniter.com\/user_guide\/concepts\/services<\/a><\/li>\n  <li>codeigniter4\/codeigniter4 \u2014 CodeIgniter 4 repository: <a href=\"https:\/\/github.com\/codeigniter4\/CodeIgniter4\" target=\"_blank\" rel=\"noopener\" style=\"text-decoration: underline;\">github.com\/codeigniter4\/CodeIgniter4<\/a><\/li>\n  <li>pocketarc\/codeigniter \u2014 maintained CI3 fork (PHP compatibility range): <a href=\"https:\/\/github.com\/pocketarc\/codeigniter\" target=\"_blank\" rel=\"noopener\" style=\"text-decoration: underline;\">github.com\/pocketarc\/codeigniter<\/a><\/li>\n<\/ul>\n","protected":false},"excerpt":{"rendered":"<p>A legacy CodeIgniter 3 + MX HMVC ERP running on an end-of-life PHP finally has to move. Here&#8217;s what CI4 genuinely changes, which PHP 8.5 features actually matter for financial data (and which are older than you think), and what the migration really costs.<\/p>\n","protected":false},"author":2,"featured_media":174,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[5,2],"tags":[34,35,38,36,33,37],"class_list":["post-172","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-case-study","category-coding","tag-codeigniter","tag-erp","tag-hmvc","tag-legacy-migration","tag-php","tag-software-maintenance"],"_links":{"self":[{"href":"https:\/\/rainverse.com\/insights\/wp-json\/wp\/v2\/posts\/172","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/rainverse.com\/insights\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/rainverse.com\/insights\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/rainverse.com\/insights\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/rainverse.com\/insights\/wp-json\/wp\/v2\/comments?post=172"}],"version-history":[{"count":2,"href":"https:\/\/rainverse.com\/insights\/wp-json\/wp\/v2\/posts\/172\/revisions"}],"predecessor-version":[{"id":175,"href":"https:\/\/rainverse.com\/insights\/wp-json\/wp\/v2\/posts\/172\/revisions\/175"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/rainverse.com\/insights\/wp-json\/wp\/v2\/media\/174"}],"wp:attachment":[{"href":"https:\/\/rainverse.com\/insights\/wp-json\/wp\/v2\/media?parent=172"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/rainverse.com\/insights\/wp-json\/wp\/v2\/categories?post=172"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/rainverse.com\/insights\/wp-json\/wp\/v2\/tags?post=172"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}