So, you've inherited a CodeIgniter project from 2014. You open the codebase, and instead of the usual application/controllers/ flat list, you see a modules/ folder. Inside it: users/, billing/, dashboard/, widgets/, each with their own controllers/, models/, and views/ subfolders. You scroll through a controller, and somewhere in the middle you spot this:
$sidebar = Modules::run('widgets/sidebar/render', $userId);
A controller calling another controller. From inside an action. Mid-render.
Welcome to HMVC.
If you've never seen it, that line probably looks wrong. If you spent a few years in CodeIgniter MX or Kohana or FuelPHP, that line probably looks completely normal. And the fact that those two reactions exist in the same industry is most of what this article is about.
HMVC, the Hierarchical Model-View-Controller, was the modular pattern of choice for a generation of PHP shops. It promised self-contained modules, plug-and-play widgets, and clean team boundaries. It delivered some of that, and it also dragged a lot of weird performance and debugging pain along for the ride. Most modern frameworks quietly stopped using it, and the patterns that replaced it look almost nothing like the original.
Let's break down what HMVC actually is, why it was attractive, where the cracks showed up, and what your options look like today if you're staring at one of these legacy codebases or thinking about reaching for the pattern in something new.
Where HMVC Came From
HMVC isn't a framework idea. It's a paper.
In 2000, Jason Cai, Ranjit Kapila, and Gaurav Pal published "HMVC: The layered pattern for developing strong client tiers" in JavaWorld. The original problem they were solving was Java client UIs: you have a complex screen with multiple panels, and each panel wants its own data, its own controller logic, and its own little view. So you let each panel be its own MVC triad, and you let those triads talk to each other in a parent-child arrangement. A controller can spawn a child controller. A child controller can return its rendered output up to its parent. The whole UI becomes a tree of MVC nodes.
That's the seed.
The PHP world picked it up about a decade later, mostly because the early-2010s LAMP stack was drowning in monolithic controllers and people were looking for any way to break things apart. CodeIgniter shops latched onto Modular Extensions (MX), an unofficial but extremely popular extension by wiredesignz that bolted HMVC onto CodeIgniter. Kohana went further and made HMVC a first-class part of the framework, with its Request::factory() and HMVC class. FuelPHP, when it launched in 2011, was built around the pattern from day one.
For a few years, "modular PHP" basically meant "HMVC."
What HMVC Actually Looks Like In Code
The mental model is simple: a controller can call another controller and get its rendered output back as a string.
In CodeIgniter MX:
class Dashboard extends MX_Controller
{
public function index()
{
$data['sidebar'] = Modules::run('widgets/sidebar/render', $this->userId);
$data['recent'] = Modules::run('activity/recent/list', $this->userId, 10);
$this->load->view('dashboard/index', $data);
}
}
Modules::run() finds the matching module under modules/widgets/controllers/sidebar.php, calls its render() method, captures whatever that method returns (or echoes), and hands it back as a string. The parent controller never touches the sidebar's model. It never knows what data the sidebar pulled. It just gets HTML.
In Kohana:
$sidebar = Request::factory('widgets/sidebar/render')
->post('user_id', $user_id)
->execute()
->body();
Same idea, more verbose, more explicit about what's happening: you're constructing a fresh request, executing it, and reading the response body. Kohana made it look like a sub-request because that's exactly what it was.
The result, on every framework that supported the pattern, was the same shape: each module is a complete miniature application. Look at a typical layout:
modules/
widgets/
controllers/
sidebar.php
navbar.php
models/
sidebar_model.php
views/
sidebar.php
navbar.php
config/
sidebar.php
billing/
controllers/
invoices.php
subscriptions.php
models/
invoice_model.php
views/
invoices/
list.php
show.php
Each module owns its routes, its data, its rendering, and often its own config. You could git rm -rf modules/billing/ and the rest of the app would mostly keep working, assuming nobody had hard-coded Modules::run('billing/...') from outside.
That last assumption was where things started getting interesting.

What HMVC Got Right
Strip away the implementation pain for a second, and there's a real idea here that's worth respecting.
Self-contained modules force good boundaries. When billing/ has its own controllers, models, and views, a developer working on billing doesn't accidentally reach into users/. The folder structure is a fence. You can argue that the fence should be drawn by namespaces and Composer packages instead of folders, and you'd be right, but in 2013, on a CodeIgniter codebase, this was the most pragmatic fence available.
Widgets become callable. The classic use case is a sidebar that needs its own data. Without HMVC, the parent controller has to know about the sidebar's needs: load the sidebar model, query for the sidebar data, pass it through the view variables, hope nobody renames anything. With HMVC, the parent says "give me the sidebar" and the sidebar takes care of itself. That's a real win in code that has dozens of these widgets.
Team boundaries match folder boundaries. If the billing team works in modules/billing/ and the auth team works in modules/auth/, your merge conflicts go down. PRs review faster. New hires can get oriented by looking at one folder.
Drop-in extensions become possible. A few CMS-shaped CodeIgniter projects took this further: a module folder with the right manifest could be installed by copying the directory in. No code-generation step, no routes file to edit, no service registration. The module declares its own routes by virtue of existing.
So the pattern wasn't a mistake. It was solving a real organizational problem in an environment that didn't have great tools for solving it any other way. Composer existed, but most CodeIgniter shops weren't using it. PSR-4 was 2013. Symfony Components were still niche outside the Symfony world.
In context, HMVC made sense.
Where The Cracks Show Up
The trouble is that "controller calling another controller as a function" is doing something subtle that wasn't obvious from the docs.
Every sub-call re-runs the framework lifecycle. When you do Modules::run('widgets/sidebar/render', $userId), you're not calling a method on an object. You're creating a new controller instance, which often means re-running the framework's controller construction (loading helpers, libraries, hooks, sometimes before_filter-style code), routing the call internally, and then unwinding it on the way back. On CodeIgniter MX, this was relatively cheap. On Kohana, where every sub-request was a real Request::factory() instance, it was more expensive than people realized.
A page that renders five widgets via Modules::run is, in performance terms, six requests stitched together. Most of the time, the user never notices. But when the page is slow and you go looking for why, the answer is rarely in any one file.
Stack traces become opaque. Picture an error thrown inside widgets/sidebar/Sidebar::render(). Your stack trace has the framework's request dispatcher in it twice: once for the parent request, once for the sub-request. If you have widgets calling widgets calling widgets, the trace looks like a hall of mirrors. Even experienced developers waste real time figuring out which level of the call tree the error came from.
Database access multiplies. Each module is "self-contained," which sounds nice until you realize that widgets/sidebar runs its own queries, and widgets/navbar runs its own queries, and activity/recent runs its own queries, and none of them know about each other. A page that should have run three queries runs fifteen, and N+1 happens at the module level instead of the row level. You can't easily eager-load anything because the parent controller doesn't know what data its children will need.
Testing gets weird. Controllers in most PHP frameworks are HTTP entry points. Their methods assume there's a request, a session, a response object, sometimes a view layer in scope. Once you start calling controllers as functions, every controller has to be testable both from HTTP and from another controller, which means every controller's contract is now twice as wide. You either accept that you can only test these things through full-stack request fakes, or you push all the real logic out of the controller and into a service, at which point the question is why was the controller the entry point at all?
Hidden coupling sneaks in. The promise of HMVC is that modules are independent. The reality is that Module A starts depending on Module B's Modules::run('moduleB/something/render') somewhere, and now you can't delete Module B without breaking Module A, and there's no compile-time check that surfaces it. The dependency graph between modules is implicit, untyped, and only knowable by grep.
Caching is a half-fix. The standard advice for HMVC performance was "cache the rendered output of each widget." Which works, until the widget's data depends on a logged-in user, or a feature flag, or session state. Then your cache key has to include all of those, and you're either caching something that's already user-specific (low hit rate) or you're risking leaking one user's data into another's cached widget (a security incident waiting to happen).
None of these are deal-breakers in isolation. Together, they meant that "HMVC for widgets" turned into "HMVC for everything" turned into "the entire app is a tree of nested controller calls and nobody can debug it."
What Modern Frameworks Did Instead
Watch what Symfony, Laravel, and Rails-influenced PHP did over the next decade. They didn't reject the modularity part of HMVC. They rejected the nested controller part.
Laravel reached for view composers and components. A view composer fires when a specific view is rendered, and it pushes the data that view needs onto the view scope:
View::composer('layouts.sidebar', function ($view) {
$view->with('recent_activity', RecentActivity::for(auth()->user()));
});
The sidebar gets its data, the parent controller never has to know about it, and there's no sub-request involved. It's just a callback fired during view resolution. Same outcome as Modules::run for the sidebar widget case, fraction of the cost.
Blade components went further:
<x-sidebar :user="$user" />
The component class loads its own data, renders its own view, and behaves like a self-contained widget. But it's a function call into a single PHP process, not a simulated HTTP sub-request. The framework knows it's a component, the IDE knows it's a component, and the stack trace looks normal.
Symfony kept the render(controller(...)) helper but treats it as the exception. You can still embed a controller's output inside a Twig template:
{{ render(controller('App\\Controller\\WidgetController::sidebar')) }}
It works, and Symfony makes it cheap by skipping the full HTTP-kernel boot when it can. But the docs steer you toward Twig template inheritance, the fragment cache (render_esi, render_hinclude) for the cases where embedding actually saves work, and reusable Twig components for the in-page widget case. Sub-controller embedding is a tool, not the default architecture.
The "module" idea got moved to packages. When PHP standardized on Composer + PSR-4, you got the modularity benefits of HMVC without the controller-call mess. A Symfony bundle is a self-contained chunk of features (controllers, services, config, templates, translations) installable via composer require. A Laravel package built with nwidart/laravel-modules gives you the Modules/Billing/, Modules/Auth/ folder layout that HMVC fans loved, while the controllers themselves still get hit through normal HTTP routes, not from inside other controllers.
The summary: the part of HMVC people genuinely needed was "modules with clear boundaries." The part they thought they needed but didn't was "controllers that can call other controllers from inside their actions." The first idea won. The second idea got walked back into the corner labeled "use only when you know exactly what you're doing."
When HMVC Still Earns Its Keep
There are a few real cases where the HMVC approach (sub-controller calls embedded in a page) is still defensible.
Plugin systems where the host doesn't know what plugins exist at compile time. A CMS that lets users install widgets through an admin UI doesn't know what those widgets are when the framework boots. Sub-request rendering, where each widget is effectively a tiny app the host calls into, is a clean way to handle that.
Heavily cached fragments with different cache lifetimes. If your page has a header that's cached for an hour, a sidebar that's cached per user for ten minutes, and a body that's not cached at all, embedding sub-controllers with their own caching contracts actually maps cleanly onto the problem. Symfony's render_esi exists for exactly this. You let an edge cache or reverse proxy assemble the page from independently-cached fragments.
Edge-side includes and fragment delivery. If you're doing real ESI or <turbo-frame>-style fragment rendering where each piece is a separate HTTP request anyway, you're already paying the sub-request cost on the network. Letting your framework model that with sub-controller calls is consistent.
Legacy migration where you can't do it all at once. If you're moving an old monolithic controller into a more component-based shape, an HMVC-style intermediate step (extract widgets into sub-controllers first, then over time replace those with view composers or components) is a reasonable transitional move. You're not committing to HMVC forever; you're using it as scaffolding.
Outside those cases, reach for the modern equivalents first. View composers, components, partials with explicit data passing, render-side caching. The plumbing is shorter and the failure modes are visible.
If You're Maintaining An HMVC Codebase Today
You probably can't (and shouldn't) rewrite the whole thing.
What you can do, when you're touching a slow page, is start unwrapping the worst offenders. Find the Modules::run calls in the hottest paths and ask two questions: does this widget need its own request lifecycle, or is it really just a partial that wants its own data? If it's the latter, you can usually replace Modules::run('widgets/sidebar/render', $userId) with a direct service call that returns view data, plus a <?php $this->load->view('partials/sidebar', $sidebarData) ?> in the parent template. One sub-request becomes zero.
When the page is doing N module calls and each one does its own database trip, batching the data fetch in the parent controller and passing it down, even if it breaks the "modules are self-contained" purity, almost always pays for itself in latency.
Most importantly: don't let the pattern dictate the approach. The HMVC purist instinct is "every widget should be a sub-controller." The pragmatic instinct is "every widget should be whatever causes the least pain to maintain and the least latency to render." The second instinct is the one that ages well.
HMVC was a reasonable solution to a real problem in a specific era of PHP. It's not evil, and the codebases written in it aren't doomed. But the pattern is doing more work than the problem requires, and once you see what view composers and components give you for a fraction of the cost, the case for nesting controllers gets thin.
Modular MVC was the goal. HMVC was one path to it. The path got better.




