You've inherited a ZF2 app. Or you wrote one in 2014 and the company still ships features through it. Either way, you open the project and there it is: a module/ directory with fifteen folders inside, each with its own Module.php, its own config/module.config.php, its own src/. It looks tidy. It looks like the kind of layout a senior engineer would draw on a whiteboard during onboarding.
Then you change one config key and three other modules break. Welcome to the question that follows every ZF2 codebase around: are these modules the architecture pattern they're advertised as, or are they just complexity wearing a hoodie?
The honest answer is: both, and which one shows up depends almost entirely on how the team used them. Let's break it down.
What a module actually is in ZF2
A ZF2 module (and the same applies to Laminas, since Zend Framework 2 was renamed to Laminas in 2019) is a directory with one mandatory file: Module.php at the root, with a Module class. The class is the contract. Anything else is convention.
The ModuleManager walks through the list of enabled modules in config/application.config.php, instantiates each Module class, and asks it questions:
namespace Catalog;
use Zend\ModuleManager\Feature\ConfigProviderInterface;
use Zend\ModuleManager\Feature\AutoloaderProviderInterface;
use Zend\Loader\StandardAutoloader;
class Module implements ConfigProviderInterface, AutoloaderProviderInterface
{
public function getConfig()
{
return include __DIR__ . '/config/module.config.php';
}
public function getAutoloaderConfig()
{
return [
StandardAutoloader::class => [
'namespaces' => [
__NAMESPACE__ => __DIR__ . '/src/' . __NAMESPACE__,
],
],
];
}
}
That's the whole shape. The ModuleManager collects getConfig() from every module, merges them into one giant array, and hands that array to the ServiceManager. The autoloader configs get registered. If your module class implements BootstrapListenerInterface or defines onBootstrap(MvcEvent $e), that runs after every module is loaded.
Nothing magical. It's a plugin loop with a config-merge step bolted on.
The architecture pattern part, and it's real
If you've only ever heard ZF2 modules described as overkill, this part may sting a bit. The pattern works when the modules are genuinely independent units of functionality that someone outside the team might want to pull in.
Consider a CMS where Blog, Comments, Newsletter, and Analytics are real, pluggable things. Each one ships its own routes, its own controllers, its own services, its own view templates. Drop the folder into module/, add the module name to application.config.php, and the feature lights up. Remove it and the feature is gone. No cleanup tour through twelve files.
That's the architecture-pattern version. Boundaries are real because the modules were designed to be optional. The merge step is a feature, not a bug, because each module legitimately wants to extend the central configuration with its own routes and services.
When a ZF2 module is shaped like this, it does what frameworks like Symfony's bundles or Drupal's modules also do well: it lets you compose an application out of features instead of weaving them through a single src/ tree. You can also distribute a module on Packagist and let other apps install it via Composer, which is genuinely useful. zf-apigility-admin, BjyAuthorize, and a long list of Zend\* modules all worked this way.
So yes, the pattern is real, and on greenfield ZF2 work where someone thought about boundaries up front, it pays off.
Where it turns into a complexity source
The pattern stops being free the moment you treat modules as "subfolders of my application that I happen to call modules." Which, in most ZF2 codebases I've read, is exactly what they are.
Config merging is order-dependent and invisible
Here's the thing about array_merge_recursive style merging, and ZF2 uses Zend\Stdlib\ArrayUtils::merge, which is similar but smarter about numeric keys. Later wins. So if Module A defines:
return [
'service_manager' => [
'factories' => [
'PaymentGateway' => Factory\StripeGatewayFactory::class,
],
],
];
And Module B defines:
return [
'service_manager' => [
'factories' => [
'PaymentGateway' => Factory\PayPalGatewayFactory::class,
],
],
];
Which one wins depends on the order in application.config.php. There's no warning, no log line, no compile-time anything. You ask the ServiceManager for 'PaymentGateway' and you get whichever module was listed last. If a developer adds a new module six months later that happens to redefine the same key, the production checkout flow quietly starts using a different gateway.
You can debug this. Zend\ModuleManager\Listener\ConfigListener exposes the merged config, and you can dump it. But you have to know to dump it. New engineers on the project rarely do. They search the codebase for PaymentGateway, find both factories, and have a bad afternoon.
Module boundaries are nominal
A module doesn't isolate anything from another module. They all share one ServiceManager, one EventManager, one router. If Module A registers a service called Logger, Module B can grab it. Which is the whole point, that's how modules communicate. But it also means the boundary you drew on the whiteboard is a social contract, not a technical one.
In practice that means a junior dev in the Catalog module needs a user's permissions and instead of going through a clean interface in the Auth module, they just pull the AuthService straight out of the ServiceManager and call its private-looking method. Six months later you can't move Auth to its own repository without breaking Catalog in three places no one documented.
This isn't ZF2's fault, strictly. The same thing happens in Symfony bundles, in Spring components, in any framework with a global DI container. But the name "module" sets up an expectation of isolation that the runtime never delivers, and that mismatch is what bites teams.

Autoloading: solving a problem that no longer exists
getAutoloaderConfig() made sense in 2012. Composer was new, PSR-0 was the autoloading spec most people used, and ZF2 shipped its own autoloader because relying on Composer for everything was still a leap of faith.
In 2020 it's mostly noise. The StandardAutoloader does what PSR-4 in composer.json does, with more ceremony:
public function getAutoloaderConfig()
{
return [
'Zend\Loader\StandardAutoloader' => [
'namespaces' => [
__NAMESPACE__ => __DIR__ . '/src/' . __NAMESPACE__,
],
],
];
}
Compare with composer.json:
{
"autoload": {
"psr-4": {
"Catalog\\": "module/Catalog/src/"
}
}
}
Same effect, less code, faster (Composer's autoloader is optimised; ZF2's StandardAutoloader walks include paths). The official Laminas docs have been recommending Composer autoloading for years. The getAutoloaderConfig method is still there for backwards compatibility, but new modules don't need to implement it. Many ZF2 codebases haven't gotten the memo and still pay the indirection cost.
If you're auditing a ZF2 app, ripping out unused getAutoloaderConfig() methods is a safe, isolated cleanup. Move every namespace to composer.json, run composer dump-autoload -o, and confirm the app still boots. It's the easiest "modernise this codebase" win on the menu.
Bootstrap events: power, traps, and a third thing
The onBootstrap method runs once after all modules are loaded. It's where teams attach event listeners, configure things based on the request, register MVC event hooks, and so on.
public function onBootstrap(MvcEvent $e)
{
$eventManager = $e->getApplication()->getEventManager();
$sm = $e->getApplication()->getServiceManager();
$eventManager->attach(MvcEvent::EVENT_DISPATCH, function (MvcEvent $event) use ($sm) {
$auth = $sm->get('AuthService');
if (! $auth->hasIdentity() && requiresAuth($event)) {
$event->setResponse($auth->buildRedirectToLogin());
}
}, 100);
}
This is powerful. It's also where every ZF2 app I've seen develops its weirdest bugs. Three things to watch:
The first is ordering. onBootstrap runs in module-load order. If Auth registers a dispatch listener at priority 100, and Catalog registers one at priority 100 too, the order between them is the order they were attached, which is the order the modules loaded. You don't notice until someone reorders application.config.php and the login redirect starts firing after the controller already ran.
The second is listener leakage. An onBootstrap listener never goes away. It stays attached for the lifetime of the request. If you attach a closure that captures $this from a Module instance, you've quietly extended the Module's lifetime to the whole request. Usually fine, occasionally a memory issue, almost always a debugging surprise.
The third is the thing nobody warns you about: onBootstrap runs for every request, including CLI requests, including the request that runs your migrations. If your onBootstrap opens a database connection unconditionally, your bin/migrate.php script now needs a working database to do --help. Guard your bootstrap logic:
public function onBootstrap(MvcEvent $e)
{
if ($e->getRequest() instanceof \Zend\Console\Request) {
return;
}
// HTTP-only setup below
}
When modules actually pull their weight
After all of that, here's the short version of when ZF2 modules earn their complexity:
When a chunk of your app is genuinely optional or pluggable, installed via Composer, removable without a migration, swappable for a third-party implementation, a module is the right shape. It gives you a single switch (application.config.php) for the whole feature and a clear seam to write integration tests against.
When you have multiple deployment variants of the same codebase, a SaaS edition with billing, a self-hosted edition without, modules let you ship one source tree and turn features on or off per deployment. This is a real win.
When you're building a framework on top of ZF2, an admin panel toolkit, a CMS, an API platform, modules let your users add and remove features without forking your code. Apigility (now Laminas API Tools) is the canonical example.
When the chunk is not any of those things, when it's just "the part of the app that handles invoices, which we'll always ship and which no one outside the team will ever pull in", it's almost always cleaner as a regular namespace in src/. You get the same separation, none of the merging surprises, none of the bootstrap-order quirks, and a much shorter onboarding doc.
The trap most ZF2 teams fall into is using modules as a folder-organisation tool ("let's put each domain in a module so it feels architecturally sound") and inheriting all the complexity costs without any of the pluggability benefits. The directories look like architecture. The runtime sees one big merged config.
A small honest take
ZF2's module system is a fine answer to a specific question: how do we let people compose a PHP application out of optional, replaceable features? When that's your actual question, modules are clean, well-thought-out, and they age better than you'd expect from a pre-PSR-4 design.
When that isn't your actual question, modules are a configuration-merging engine pretending to be an architecture pattern. The boundaries you think they enforce don't exist at runtime, the config merge is a quiet global namespace, and the bootstrap hooks invite a kind of action-at-a-distance bug that's painful to track down years later.
If you maintain a ZF2 codebase, the most useful thing you can do this week is open application.config.php, look at the list of modules, and ask of each one: if I deleted this from the list, would the feature disappear cleanly? For the ones where the answer is yes, your module system is working. For the ones where the answer is "no, half a dozen other modules would break", you've got namespaced folders, not modules, and you can stop paying for the things modules charge you for.
Either way, knowing which is which is the first move. The architecture pattern and the complexity source live in the same module/ directory. They look identical from the outside. The difference is whether the boundary on the whiteboard matches the one in the running code.




