Some software doesn’t get replaced — it just keeps working, year after year, until the ground underneath it quietly disappears.
That’s the situation with a certain class of business system: a custom ERP built in the mid-2010s on CodeIgniter 3 with the MX HMVC extension, dutifully recording invoices, stock movements, and payments. It does its job. The trouble is everything around it has moved on — the framework, the language, and the extension that made its architecture feel modern at the time.
This is a practical account of what it takes to move such a system to CodeIgniter 4 on PHP 8.5 — what genuinely improves, what the version numbers actually mean, and where the real work hides.
Why CI3 + HMVC systems age badly
CodeIgniter 3 was a fine framework for its era. But its design assumptions belong to a different decade of PHP:
- The global singleton loader. Everything arrives through
$this->load->model(...),$this->load->library(...). Dependencies are pulled, never injected — so classes are hard to test in isolation and hard to swap. - No native namespacing. CI3 predates PSR-4. Organising code meant HMVC’s module folders, not namespaces.
- HMVC was the workaround. The MX HMVC extension let you call one controller from another — a “triangular” request — so teams could build widget-like modules. It solved a real problem, but via cross-controller coupling rather than a service layer.
- The framework is no longer maintained. CodeIgniter 4 is the maintained line. The original CI3 branch is no longer developed, and the community MX HMVC extension has been abandoned — its repository is gone.
- PHP 8.0 has been end-of-life since 26 November 2023. Running production financial data on an EOL interpreter means no security patches.
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.
What CodeIgniter 4 genuinely changes
This is where the marketing and the reality diverge, so let’s be precise.
PSR-4 modules replace HMVC
CI4 is namespaced and Composer-native. The module folder hack becomes a first-class namespace mapped in app/Config/Autoload.php:
public $psr4 = [
APP_NAMESPACE => APPPATH,
'Modules' => ROOTPATH . 'modules',
];
What it buys you: real encapsulation. A module is a namespace with its own models and controllers, not a folder that controllers reach into.
A service registry — not an autowiring container
CI4 adds Services: a central registry that creates and shares class instances (CI4 Services docs). It is best described as a factory, not a Laravel-style dependency-injection container.
This distinction matters, because a common mistake is to assume CI4 will inject typed constructor dependencies automatically. It won’t. This does not work:
// ✗ CI4 does not auto-resolve constructor dependencies
public function __construct(
private InvoiceModel $invoices,
private PdfGenerator $pdf,
) {}
What does work is resolving explicitly — by configuration, through the registry:
// Config\Services — define the factory once
public static function invoices($getShared = true)
{
return $getShared
? static::getSharedInstance('invoices')
: new \App\Models\InvoiceModel();
}
// In a controller
public function __construct()
{
$this->invoices = service('invoices'); // shared instance
$this->pdf = service('pdf');
}
The win is real — dependencies become swappable and testable — but it’s configuration-driven, and worth knowing before you plan the work.
Models become richer
CI4 models return Entity objects (opt-in via $returnType), and support soft deletes and automatic timestamps as first-class features. For an ERP, that’s meaningful: an invoice becomes an object with behaviour instead of an array you hope has the right keys.
Filters replace hooks — and they compose
The hooks system becomes Filters, applied per-route-group with arguments:
$routes->group('api', ['filter' => ['auth', 'role:admin', 'audit']], function ($routes) {
// ...
});
For financial systems, being able to declare auth → role → audit trail as a composable chain on a route group — rather than sprinkling hook logic — is a genuine structural improvement.
Tooling comes with the framework
CI4 ships with the spark CLI (migrations, seeders, generators) and PHPUnit integration. The days of hand-rolled test scaffolding and ad-hoc schema scripts end.
The PHP 8.5 features that actually matter here
Modern PHP does more for a financial system than the framework does. But be careful with version claims — several features people credit to 8.5 are much older.
Genuinely new in PHP 8.5 (released 20 November 2025 — php.net):
- The pipe operator (
|>) — chain transformations left-to-right instead of nesting calls:
$total = $lineItems
|> array_filter(..., fn ($i) => ! $i->isVoid())
|> array_map(..., fn ($i) => $i->subtotal())
|> array_sum(...);
clone with— copy an object while overriding properties, which makes immutable value objects practical:
$corrected = clone($invoice, ['status' => 'reissued']);
#[\NoDiscard]— mark a function whose return value must be consumed, so a silently-ignored result (the classic source of accounting bugs) becomes a warning:
#[\NoDiscard]
function postLedgerEntry(Entry $entry): LedgerResult { /* ... */ }
Worth knowing: two features often credited to 8.5 arrived earlier — constructor property promotion is PHP 8.0, and readonly properties are PHP 8.1. 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.
Version requirements you should plan around
- PHP for CI4 — 8.2 or newer
- PHP 8.5 — Requires CodeIgniter 4.7.0 or later
- PHP 8.4 — Requires CodeIgniter 4.6.0 or later
- Official CI3 ceiling — PHP 8.1 (a community fork extends CI3 to 8.2–8.6)
- PHP EOL dates — 8.0 → 26 Nov 2023 · 8.1 → 31 Dec 2025 · 8.2 → 31 Dec 2026
Sources: CI4 server requirements; pocketarc/codeigniter fork.
That “official CI3 ceiling = PHP 8.1” line is the one that forces the decision: you cannot simply upgrade PHP and keep CI3. You either move to CI4, or adopt a maintenance fork and accept that you’re on a patched legacy branch.
What the migration actually costs
Honesty section. CI4 is a rewrite of the application layer, not a version bump — there is no automated CI3→CI4 converter.
- Syntax overhaul.
$this->input->post()becomes$this->request->getPost().$this->load->disappears in favour of helpers and services (view(),model(),service(),db_connect()). - Sessions and validation are rewritten conceptually, not just re-signed. Form validation in particular moves to a rule-set model that most CI3 codebases touch everywhere.
- HMVC “triangular” calls must be untangled. Every place a controller invoked another controller becomes a service call — which is the point, but it’s the largest structural chunk of work.
- Financial integrity needs an explicit test net. For an ERP, “the page loads” is not verification. Ledger totals, tax calculations, and stock reconciliation need regression tests before the migration starts, so you can prove parity after.
- Realistic effort: multi-month for a non-trivial ERP, dominated by the HMVC untangling and validation rewrite — not by the framework itself.
A practical checklist
- Freeze the behaviour. Capture current outputs (invoices, ledgers, reports) as golden fixtures. Migration without parity tests is a gamble.
- Inventory the HTTP surface. Every route, every
$this->input->call, every session use. - Map HMVC call graph. Each cross-controller call becomes a service; draw it before you cut it.
- Define services first. Build
Config\Servicesfactories for models, PDF, mail, and anything injectable. - Move to namespaces and PSR-4 module by module, keeping the old code runnable until parity is proven.
- Convert filters (auth/role/audit) as route-group chains, not scattered hooks.
- Adopt CI4 validation per form, with tests.
- Pin versions: CI ≥ 4.7 for PHP 8.5; confirm your hosting supports the target runtime.
- Run parity tests against the frozen fixtures before cutover.
The decision rule
Don’t migrate because the version number looks old. Migrate when the EOL risk plus the cost of building on a dead framework exceeds the cost of the move — and for a system holding financial records on an end-of-life interpreter, that threshold has usually already passed.
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.
Information as of October 2026; framework and language versions change — verify against the linked documentation before planning work. Nothing here is legal or financial advice.
Sources
- PHP — 8.5 release announcement: php.net/releases/8.5
- PHP — 8.5 new features (NoDiscard, clone with, pipe): php.net/manual/migration85
- PHP — 8.0 release (constructor property promotion): php.net/releases/8.0
- PHP — 8.1 release (readonly properties): php.net/releases/8.1
- PHP.Watch — readonly properties (8.1): php.watch
- CodeIgniter 4 — server requirements (PHP 8.2+; PHP 8.5 needs CI 4.7.0+; EOL dates): codeigniter.com/user_guide/intro/requirements
- CodeIgniter 4 — Services (factory/registry): codeigniter.com/user_guide/concepts/services
- codeigniter4/codeigniter4 — CodeIgniter 4 repository: github.com/codeigniter4/CodeIgniter4
- pocketarc/codeigniter — maintained CI3 fork (PHP compatibility range): github.com/pocketarc/codeigniter