Home > Blog > CodeIgniter HMVC on PHP 8.2

CodeIgniter HMVC: Modules, PHP 8.2 Fixes, and the CodeIgniter 4 Question

Tue, 04.04.2023
Thu, 30.07.2026
Illustration of CodeIgniter HMVC modules feeding into a PHP 8.2 deprecation warning being resolved in a code editor

Most people reading this are not starting a new CodeIgniter HMVC project. They are keeping an old one running.

That is the honest state of this topic. Modular Extensions – HMVC gave CodeIgniter 3 something the framework never shipped: real modules, each with its own controllers, models, and views, callable from anywhere in the application. A lot of software got built that way between roughly 2011 and 2019, and a lot of it is still in production. Then PHP 8.2 landed, deprecated dynamic properties, and every one of those applications started filling its logs with the same warning.

This guide covers three things, in the order people actually need them: the fix for the PHP 8.2 deprecation notices, how the HMVC module structure works if you are inheriting a codebase you did not write, and what your options are on CodeIgniter 4 — where the wiredesignz extension does not work at all. We do legacy CodeIgniter work, so this is written from the maintenance side rather than the tutorial side.

If you landed here from a search for a specific error message, skip straight to the next section.

Fixing “Creation of dynamic property CI::$load is deprecated”

This is the error that brings most people here:

Deprecated: Creation of dynamic property CI::$load is deprecated
in /application/third_party/MX/Base.php on line NN

You may also see it as CI::$app->load, or against other properties — CI::$config, CI::$router, CI::$db. Same cause, same fix.

Worth knowing up front: this is not really an HMVC problem. It is a CodeIgniter 3 problem that HMVC makes louder. The framework core throws the same class of notice on its own — there are reports against CI_URI::$config in system/core/URI.php and CI_Loader::$input in system/core/Loader.php with no HMVC involved at all. Patching the extension cleans up the extension. The core will keep going.

Why it happens

PHP 8.2 deprecated dynamic properties. Assigning to a property that was never declared on the class now emits a deprecation notice on first assignment:

class Example {}

$e = new Example();
$e->something = 'value';
// Deprecated: Creation of dynamic property Example::$something is deprecated

Modular Extensions – HMVC was written years before this rule existed, and it leans on dynamic assignment deliberately. The CI class in MX/Base.php exists to mirror CodeIgniter’s loaded components onto module controllers, and it does that by assigning properties it never declared. Under PHP 8.1 that was normal. Under 8.2 it is a deprecation. Under a future major version it will very likely be an error.

Two things worth knowing before you start:

Your error message names the exact file and line. Do not go hunting. In a standard install it points at application/third_party/MX/Base.php, but if a previous developer moved things around it may not. Trust the trace over any tutorial, including this one.

Nothing is broken yet. These are deprecation notices, not fatal errors. The application still runs. What they do is flood your logs, slow things down under volume, and occasionally leak into output if display_errors is on in an environment where it should not be. So this is urgent in the sense that it will get worse, not in the sense that the site is down.

Fix 1: #[AllowDynamicProperties] — fastest

PHP 8.2 shipped an attribute that opts a class back into the old behaviour. Add it above the class declaration in MX/Base.php:

#[AllowDynamicProperties]
class CI
{
    // ... existing class body unchanged
}

Two details that make this the pragmatic choice:

The effect is inherited. Attributes themselves are not inherited in PHP, but the dynamic-property permission granted by AllowDynamicProperties is — child classes of a marked class also allow dynamic properties without declaring the attribute themselves. Patch the base class and the subclasses are covered.

It is safe on older PHP. In PHP 7.x, # opens a comment, so the whole line is ignored rather than causing a parse error. If your deployment pipeline still touches PHP 7 anywhere, this will not break it.

If the file uses namespaces, you need the leading backslash or an import, or PHP will look for the attribute inside the current namespace and silently fail to apply it:

#[\AllowDynamicProperties]
class CI { }

You will likely need the same attribute on MX_Controller and MX_Model in third_party/MX/Controller.php and third_party/MX/Model.php, since those assign loaded libraries onto themselves the same way. Run the app, read the log, patch what actually fires — rather than pre-emptively decorating every class in the directory.

Fix 2: Declare the properties — cleaner

The attribute silences the warning. Declaring the properties fixes the underlying design:

class CI
{
    public $load;
    public $config;
    public $router;
    public $uri;
    public $lang;
    public $input;
    public $output;
    public $security;
    public $benchmark;
    public $db;

    // ... existing class body
}

The catch is that you have to know the full set, and the full set depends on which libraries your application autoloads. Miss one and it still warns. Work from your log: each notice names the property, so collect them over a full pass through the application before you start writing declarations.

This is the right fix if you expect to be on this codebase for years, or if you are heading toward PHP 9 where dynamic properties are expected to become an error rather than a notice. It is the wrong fix if you are migrating off CodeIgniter 3 in the next six months — you would be doing careful work on code you are about to delete.

Fix 3: Suppress the notice — do this only as a stopgap

// In index.php, or your environment's error_reporting call
error_reporting(E_ALL & ~E_DEPRECATED & ~E_STRICT);

This hides every deprecation notice in the application, not just this one. That means the next PHP version’s warnings — the ones that tell you what will break in the version after — are hidden too. It is a reasonable thing to do at 2am to stop a log partition filling up. It is not a fix, and if you use it, write yourself a ticket.

Verifying it worked

Deprecation notices fire once per property per request, so a single page load is not a test. Clear your log, then exercise the routes that load the most libraries — usually an authenticated dashboard rather than the homepage — and check the log is still empty:

> application/logs/log-$(date +%Y-%m-%d).php
# hit several routes, then:
grep -c "dynamic property" application/logs/log-$(date +%Y-%m-%d).php

If you patched MX/Base.php and the notices moved to a different class, that is progress, not failure. Patch the next one.

There is no upstream fix coming

Before you go looking for a patched release: there isn’t one, and there is nobody to wait for.

The original Modular Extensions – HMVC lives in a Bitbucket repository under Mercurial, and Bitbucket removed Mercurial support in 2020. The forks that exist on GitHub — codinghamster, natanfelles, brianwozeniak, invitecomm, and others — are mirrors with small routing or Composer improvements. None of them advertises PHP 8.2 compatibility. The codinghamster fork is the most practical of them if you want Composer installation rather than copied directories, but it will not solve this for you either.

That means the patch is yours to own, which makes the next point matter.

Keep a record of the patch

You are editing a third-party library inside third_party/. The next developer who reinstalls the extension will wipe your fix without knowing it existed. Leave a comment above the attribute explaining what it is and why, and note it in the project README. If you installed via Composer, express it as a patch file rather than editing vendor/ — otherwise the next composer install silently undoes your afternoon.

The PHP Version Problem Behind All of This

It is worth understanding why these notices appeared at all, because the answer determines how much more effort CodeIgniter 3 deserves.

CodeIgniter 3’s final release is 3.1.13. The branch is in maintenance mode — critical security patches, no active development. Versions 3.1.12 and 3.1.13 added compatibility with PHP 8.0 and PHP 8.1, and that is where official support stops. There is no CodeIgniter 3 support for PHP 8.2 or newer.

Now put that next to PHP’s own release schedule. PHP 8.1 reached end of life on 31 December 2025. The versions still receiving security support are 8.2, 8.3, and 8.4 — and PHP 8.2 is itself security-only, reaching end of life on 31 December 2026.

Read those two paragraphs together and the position is uncomfortable: the newest PHP your framework officially supports no longer gets security patches, and every PHP version that does get patched is officially unsupported by your framework. There is no configuration that is fully supported on both sides. You are choosing which kind of unsupported you prefer.

Running CI3 on PHP 8.2 or later is what most teams pick, and it works — with the deprecation notices this guide fixes, and with the understanding that you are now the maintainer of your own compatibility layer. That is a real, defensible choice. It is just worth making it deliberately rather than discovering it from a log file, and worth noting that 8.2 buys you less runway than it looks like.

Some of the patching is genuinely small. The extension’s own documentation, for instance, tells you to extend the form validation library like this so that callbacks work:

// application/libraries/MY_Form_validation.php
class MY_Form_validation extends CI_Form_validation
{
    public $CI;
}

That public $CI; is exactly the same declaration this whole guide is about — someone hit the dynamic-property problem long before PHP made it a deprecation, and solved it by declaring the property. The pattern you need is already in the codebase; PHP 8.2 just made it mandatory everywhere.

How HMVC Actually Works

If you are maintaining a codebase someone else wrote, this is the mental model you need.

MVC splits an application three ways. The model holds data and business rules, the view renders output, the controller mediates. One set of each, application-wide.

HMVC — Hierarchical Model-View-Controller — makes that triad repeatable. Instead of one controllers directory for the whole application, each module gets its own controllers, models, and views, and modules can call each other. A blog module can render a widget from a comments module without either one knowing much about the other.

The word doing the work is hierarchical. A module’s request can trigger another module’s request, and that one can trigger a third. It is MVC that nests.

In practice you get:

  • Modules you can lift out. A payments module with its own MVC set can move to another project by copying a directory, assuming its dependencies come with it.
  • Widget-style composition. The parts of a page that repeat across templates — a cart summary, a notification bell — become module calls rather than helper functions with view fragments bolted on.
  • Merge conflicts that stay local. Two developers on two modules touch two directory trees. On flat MVC they both touch controllers/.

The cost is indirection. Tracing a request through four nested module calls is genuinely harder than reading one controller, and HMVC codebases tend to accumulate module-to-module dependencies that nobody drew a diagram of. On a small application it is overhead you will not recover.

CodeIgniter 3 never shipped HMVC. The pattern arrived through wiredesignz’s Modular Extensions – HMVC, which is why almost every CI3 HMVC codebase you will meet has the same third_party/MX/ directory in it.

Get a Custom CodeIgniter Solution Now!

Installing Modular Extensions – HMVC on CodeIgniter 3

Most readers already have this installed and are debugging it rather than adding it. Either way, knowing where the pieces live is what makes the codebase readable.

File placement

Three things get dropped into an existing CodeIgniter 3 application:

application/
├── core/
│   ├── MY_Loader.php      # extends CI_Loader, adds module awareness
│   └── MY_Router.php      # extends CI_Router, resolves module routes
├── third_party/
│   └── MX/                # the extension itself
│       ├── Base.php       # the CI class — where the PHP 8.2 notice fires
│       ├── Ci.php
│       ├── Config.php
│       ├── Controller.php # MX_Controller
│       ├── Lang.php
│       ├── Loader.php
│       ├── Model.php      # MX_Model
│       ├── Modules.php
│       └── Router.php
└── modules/               # your modules live here

The two MY_ files are the integration point. CodeIgniter 3 automatically loads any class in application/core/ prefixed with the value of $config['subclass_prefix']MY_ by default. That is how a third-party library takes over routing and loading without you editing framework files.

If your subclass_prefix has been changed, those files must be renamed to match, or the extension silently does nothing and every module route 404s. This is a common cause of “I installed it and nothing happened.”

Module structure

application/modules/
├── blog/
│   ├── controllers/
│   │   └── Blog.php
│   ├── models/
│   │   └── Blog_model.php
│   └── views/
│       └── index.php
└── comments/
    ├── controllers/
    │   └── Comments.php
    ├── models/
    │   └── Comments_model.php
    └── views/
        └── widget.php

Each module mirrors the shape of application/ itself. A module can also carry its own config/, helpers/, language/, and libraries/ directories, and the loader will find them.

Pointing the loader at your modules

If you keep modules somewhere other than application/modules/, declare it in application/config/config.php:

$config['modules_locations'] = [
    APPPATH . 'modules/' => '../modules/',
];

The array key is the absolute path; the value is the path relative to the front controller. Getting the second one wrong produces views that cannot be found while controllers resolve fine — a confusing failure worth recognising.

Per-module routes and autoloading

Each module can carry its own config/routes.php, which keeps routing next to the code it routes to:

// application/modules/blog/config/routes.php
$route['blog'] = 'blog/index';
$route['blog/(:any)'] = 'blog/view/$1';

Module controllers also accept an $autoload property, which runs before the constructor:

class Blog extends MX_Controller
{
    public $autoload = [
        'helper'    => ['url', 'text'],
        'libraries' => ['pagination'],
        'model'     => ['blog_model'],
    ];
}

This is per-controller. For module-wide autoloading, use application/modules/blog/config/autoload.php instead — the two can be combined.

Controllers and models

Module controllers extend MX_Controller rather than CI_Controller:

<?php
defined('BASEPATH') OR exit('No direct script access allowed');

class Blog extends MX_Controller
{
    public function __construct()
    {
        parent::__construct();
        $this->load->model('blog_model');
    }

    public function index()
    {
        $data['posts'] = $this->blog_model->get_published();
        $this->load->view('index', $data);
    }

    public function view($slug = null)
    {
        $post = $this->blog_model->get_by_slug($slug);

        if (empty($post)) {
            show_404();
        }

        $this->load->view('single', ['post' => $post]);
    }
}

Two things differ from a standard CI3 controller. $this->load->view('index') resolves inside the module’s own views/ directory first, so modules do not collide over view names. And parent::__construct() is not optional here — skip it and the loader is never initialised, which surfaces as a null-property error that looks nothing like its cause.

Models extend MX_Model:

<?php
defined('BASEPATH') OR exit('No direct script access allowed');

class Blog_model extends MX_Model
{
    public function get_published($limit = 10)
    {
        return $this->db
            ->where('status', 'published')
            ->order_by('published_at', 'DESC')
            ->limit($limit)
            ->get('posts')
            ->result();
    }

    public function get_by_slug($slug)
    {
        return $this->db
            ->where('slug', $slug)
            ->get('posts')
            ->row();
    }
}

If you have seen MY_Controller extends MX_Controller in a codebase, that is a project-level base class — shared authentication, a common layout, that sort of thing — sitting between the extension and the module controllers. It is a normal pattern, not part of the extension.

Calling One Module From Another

This is the part that justifies HMVC. Everything above is directory organisation; this is the actual capability.

Modules::run() — render and output

// Inside a blog view, embed the comments widget for this post
echo Modules::run('comments/widget/render', $post->id);

Modules::run() executes the named module’s controller method and returns its output as a string. Output is buffered, so $this->load->view() inside the called controller works exactly as it would normally — you do not need to return anything. The called module loads its own models and renders its own views. The caller knows a route, not an implementation.

The path argument has three forms, and picking the wrong one is the most common reason a call silently returns nothing:

// Module and controller names differ — you must name the method, even 'index'
Modules::run('comments/widget/render', $post->id);

// Module and controller names match, method is not 'index'
Modules::run('comments/render', $post->id);

// Module and controller names match and the method is 'index'
Modules::run('comments', $post->id);

Parameters after the path are optional and unlimited.

$this->load->module() — use it like a library

class Blog extends MX_Controller
{
    public function view($slug = null)
    {
        $this->load->module('comments');

        $post = $this->blog_model->get_by_slug($slug);
        $count = $this->comments->count_for_post($post->id);
    }
}

Note what happens here: the loaded module controller becomes a property on the calling controller, so you reach it as $this->comments, not through a return value. It then behaves like a library — except that it has its own models and libraries, loaded independently of the caller. If the controller name matches the module name you can pass just the module name; otherwise use module/controller.

Use Modules::run() when you want rendered markup to drop into a template. Use $this->load->module() when you want to call methods and work with what they return.

One caution learned the hard way on codebases like this: Modules::run() inside a loop is a performance trap. Each call is a full controller instantiation with its own model loading and query execution. Rendering a widget once per row across a fifty-row table means fifty controller boots. If a page is inexplicably slow in an HMVC application, count the Modules::run() calls in the view before you look anywhere else.

Does CodeIgniter 4 Support HMVC?

Short answer: not HMVC as such, and the wiredesignz extension does not work on it. But CodeIgniter 4 solves the same problem a different way, and the replacement is better than what it replaces.

What changed

CodeIgniter 4 ships a PSR-4 autoloader and namespaced classes. There is no MY_Loader, no subclass_prefix magic, no MX_Controller. Modular Extensions – HMVC hooks into machinery that no longer exists, so there is nothing to install and no port to wait for.

What CodeIgniter 4 has instead is Code Modules — a first-class feature in the official documentation. There is no enforced module structure or hierarchical directory scan. Instead, you register a namespace and the framework finds your code through it.

Setting up a module

Create the directory anywhere you like — alongside app/, or inside it:

project/
├── app/
├── modules/
│   └── Blog/
│       ├── Config/
│       │   └── Routes.php
│       ├── Controllers/
│       │   └── Blog.php
│       ├── Models/
│       │   └── BlogModel.php
│       └── Views/
│           └── index.php
├── public/
└── system/

Register the namespace in app/Config/Autoload.php:

public $psr4 = [
    APP_NAMESPACE => APPPATH,
    'Modules\Blog' => ROOTPATH . 'modules/Blog',
];

Then the controller is an ordinary namespaced class:

<?php

namespace Modules\Blog\Controllers;

use CodeIgniter\Controller;
use Modules\Blog\Models\BlogModel;

class Blog extends Controller
{
    public function index()
    {
        $model = new BlogModel();

        return view('Modules\Blog\Views\index', [
            'posts' => $model->getPublished(),
        ]);
    }
}

And routes point at the namespace:

$routes->group('blog', ['namespace' => 'Modules\Blog\Controllers'], static function ($routes) {
    $routes->get('/', 'Blog::index');
    $routes->get('(:segment)', 'Blog::view/$1');
});

What you gain and what you give up

Gained: standard PHP. Namespaces and PSR-4 are language features, not a patched loader. Composer can install a module as a package. Your IDE resolves classes properly. No third-party extension sits between you and the framework, which means no PHP 8.2 patch to maintain.

Given up: the automatic hierarchy. There is no direct Modules::run() equivalent — no built-in “execute that module’s controller and hand me the HTML.” For view composition in CodeIgniter 4 you use view cells, which call a class method and return output, and which are honestly a cleaner mechanism for the widget case than a nested controller call ever was.

If the honest answer you were looking for is whether you can lift a CI3 HMVC application onto CI4 unchanged: no. The module concept survives the move. The code does not.

Should You Stay on CodeIgniter 3 HMVC?

We do legacy CodeIgniter work, so treat what follows accordingly — but the answer is genuinely “it depends,” and the deciding factors are not the ones people usually reach for.

Staying is defensible when:

  • The application is stable and feature-frozen. Something that runs an internal process nobody is changing does not need a modern framework; it needs a working one.
  • It is not internet-facing, or sits behind enough network controls that an unpatched framework is not your top risk.
  • The remaining lifespan is short. Migrating an application you plan to retire in eighteen months is rarely worth it.
  • A few #[AllowDynamicProperties] attributes genuinely are the whole problem, and you have run the app end to end to confirm that.

Migrating starts paying when:

  • The application is public and handles anything sensitive. You are running a framework in security-patch-only mode on a PHP version it never officially supported. Both halves of that are your problem now, and the security basics are harder to hold when the framework underneath is not being patched.
  • You are still adding features. Every new feature written into CI3 HMVC is more to migrate later, at a worse exchange rate.
  • Hiring is getting hard. Developers who know CodeIgniter 3 and Modular Extensions – HMVC specifically are a shrinking pool, and there is no official documentation to onboard them with — HMVC was never a CodeIgniter feature, and the extension’s own repository has been effectively frozen since Bitbucket dropped Mercurial. Bringing in PHP developers who have not seen this stack before costs real onboarding time.
  • The compatibility patching has started spreading. One attribute in MX/Base.php is a fix. Fifteen scattered across third_party/ and system/core/ is a fork you did not mean to create.

The middle path most teams actually take: patch the PHP 8.2 notices this week so the logs are clean and the pressure is off, then plan the modernisation properly over a quarter or two — module by module, behind a router that sends some paths to the old application and some to the new one. Modules make this less painful than it sounds, which is one of the few places where an HMVC structure genuinely pays back at the end of its life rather than the beginning.

The move does not have to be to CodeIgniter 4. If you are rewriting substantial parts anyway, Laravel is worth comparing — it has no HMVC extension either, and does not need one, since service providers and namespaced packages cover the same ground. We have written a fuller CodeIgniter versus Laravel comparison if that is the decision in front of you. For the mechanical side of a CI3-to-Laravel move, the open-source legacy-to-laravel project provides compatibility shims for common CodeIgniter 3 patterns, which can shorten the first phase considerably.

Conclusion

If you came here with a log full of Creation of dynamic property CI::$load is deprecated, the fix is one attribute in third_party/MX/Base.php, and it will take you about five minutes. Do that first.

Then decide what you are actually doing with the application. Patching PHP compatibility into CodeIgniter 3 works, and it will keep working for a while, but each release makes it a slightly worse deal. CodeIgniter 4 has no HMVC extension and does not need one — namespaced Code Modules do the job with plain PHP and nothing sitting between you and the framework.

We stabilise, patch, and migrate legacy CodeIgniter applications, including CodeIgniter 3 HMVC codebases where the original developers are long gone. If you want a straight assessment of whether yours is worth migrating or worth leaving alone, get in touch — or run the numbers yourself with our development cost calculator first.

Contact Our CodeIgniter Experts Now!
Abidhusain Chidi, CEO and Founder of QalbIT Infotech Pvt Ltd, wearing a white shirt and glasses, facing forward with a confident and focused expression.
Abidhusain Chidi

Leading QalbIT Infotech Pvt Ltd, he brings over a decade of expertise in web, mobile, and cloud technologies, driving digital success for startups and businesses. His strategic approach to SaaS, PaaS, and BaaS solutions delivers innovative, scalable results tailored to client needs.

  • codeigniter
  • HMVC

Frequently asked questions