laminas/laminas-modulemanager
Laminas ModuleManager provides the infrastructure for building modular Laminas applications: it discovers and loads modules, resolves dependencies, merges configuration, and triggers lifecycle events for extensible bootstrap and application setup.
Install it directly only if you are not already on Laminas MVC (which pulls it in transitively):
composer require laminas/laminas-modulemanager
Look first at three files in an existing Laminas MVC app:
config/application.config.php — the modules list and module_listener_options drive everything.module/<Name>/src/Module.php (legacy: module/<Name>/Module.php) — the class the manager instantiates for each enabled module.module/<Name>/config/module.config.php — the array returned by Module::getConfig() and merged into the application config.A minimal application.config.php:
<?php
declare(strict_types=1);
return [
'modules' => [
'Laminas\Router',
'Laminas\Validator',
'Application',
],
'module_listener_options' => [
'module_paths' => ['./module', './vendor'],
'config_glob_paths' => ['./config/autoload/{,*.}{global,local}.php'],
'config_cache_enabled' => false, // true in production
'config_cache_key' => 'app_config',
'module_map_cache_enabled' => true,
'module_map_cache_key' => 'app_modules',
'cache_dir' => './data/cache',
'use_module_autoloaders' => true,
'use_module_configs' => true,
],
];
First use case — enable a module. Create or install the module, ensure its namespace is autoloadable (Composer PSR-4 is the normal path), then add the module name to modules. If it is a local module under module/, the namespace must match the module name and resolve to a Module class (e.g. Reporting → Reporting\Module). Nothing else is required: the manager resolves the class, fires lifecycle events, and merges its config.
Second use case — change a config value. Either edit the owning module's module.config.php or add an override in config/autoload/*.local.php. Local files are merged last, so they win. In development, keep config_cache_enabled false; otherwise your edits silently do nothing until the cache is cleared.
If you are wiring module loading outside MVC, the manager is just a small event-driven object. Create it, attach a ConfigListener, and read the merged array:
use Laminas\EventManager\EventManager;
use Laminas\ModuleManager\ModuleManager;
use Laminas\ModuleManager\Listener\ConfigListener;
use Laminas\ModuleManager\Listener\ListenerOptions;
$options = new ListenerOptions([
'module_paths' => [__DIR__ . '/module'],
'config_glob_paths' => [__DIR__ . '/config/autoload/{,*.}{global,local}.php'],
'config_cache_enabled' => false,
]);
$manager = new ModuleManager(['Application', 'Reporting']);
$manager->setEventManager(new EventManager());
$configListener = new ConfigListener($options);
$manager->getEventManager()->attach($configListener);
$manager->loadModules();
$config = $configListener->getMergedConfig(); // flat array, ready to use
Config-first modules. Most day-to-day work never touches the manager API. A module is a namespace plus a Module.php that implements ConfigProviderInterface:
<?php
declare(strict_types=1);
namespace Reporting;
use Laminas\ModuleManager\Feature\ConfigProviderInterface;
use Laminas\ModuleManager\Feature\ServiceProviderInterface;
final class Module implements ConfigProviderInterface, ServiceProviderInterface
{
public function getConfig(): array
{
return include __DIR__ . '/config/module.config.php';
}
public function getServiceConfig(): array
{
return [
'factories' => [
Service\ReportService::class => Service\ReportServiceFactory::class,
],
];
}
}
Keep module.config.php returning plain arrays only. Avoid closures, new objects, and environment reads inside it — those break config caching and make merge order hard to reason about.
Use feature interfaces deliberately.
| Interface | Method | Typical use |
|---|---|---|
ConfigProviderInterface |
getConfig(): array |
Module configuration |
ServiceProviderInterface |
getServiceConfig(): array |
Register factories/aliases (MVC ServiceListener) |
RouteProviderInterface |
getRouteConfig(): array |
Router config |
InitProviderInterface |
init(ModuleManagerInterface $manager) |
Cross-module setup, early hooks |
BootstrapListenerInterface |
onBootstrap(EventInterface $e) |
Work that needs merged config / container |
DependencyIndicatorInterface |
getModuleDependencies(): array |
Force ordering against other modules |
AutoloaderProviderInterface |
getAutoloaderConfig(): array |
Legacy custom autoloaders |
Choose init() for wiring that must happen while modules load, and onBootstrap() for anything that needs the fully merged config service. onBootstrap fires after config merging, so it is the safe default.
Dependency-aware ordering. If module B's config must land after module A's, declare it:
public function getModuleDependencies(): array
{
return ['Laminas\Router', 'Laminas\Validator'];
}
The manager uses this information for ordering, but the modules array remains the fallback. Keep both consistent; do not rely on filesystem discovery order.
Event-driven customization. The manager exposes three useful points on its event manager: ModuleEvent::EVENT_LOAD_MODULE_RESOLVE, EVENT_LOAD_MODULE, and EVENT_LOAD_MODULES_POST. A common pattern is an audit/listener aggregate:
use Laminas\EventManager\AbstractListenerAggregate;
use Laminas\EventManager\EventManagerInterface;
use Laminas\ModuleManager\ModuleEvent;
final class ModuleAuditListener extends AbstractListenerAggregate
{
public function attach(EventManagerInterface $events, $priority = 1)
{
$this->listeners[] = $events->attach(
ModuleEvent::EVENT_LOAD_MODULES_POST,
[$this, 'onLoaded'],
$priority
);
}
public function onLoaded(ModuleEvent $event): void
{
foreach ($event->getTarget()->getLoadedModules() as $name => $module) {
// log, assert, or inspect $name / get_class($module)
}
}
}
Attach listeners before calling loadModules(). A listener on EVENT_LOAD_MODULES_POST with a lower priority than the ConfigListener can read $event->getConfig() to see the merged result.
Environment-specific module lists. Because application.config.php is plain PHP, you can compute the modules array from environment variables to enable development or debug modules only locally. This is the idiomatic ZF2/Laminas way and avoids editing production config.
Caching. In production, switch on config_cache_enabled and module_map_cache_enabled, and point cache_dir at a writable directory. The cache removes repeated include of every module.config.php and class-map resolution. In development, leave it off — the number one source of "my config change didn't apply" is a stale cache.
Standalone and Mezzio integration. For a CLI tool or a non-MVC app, the manual ModuleManager + ConfigListener snippet above is enough. Under Mezzio, prefer laminas/laminas-config-aggregator and add laminas/laminas-config-aggregator-modulemanager, whose ModuleManagerProvider bridges module-based config into the aggregator pipeline. Do not bolt ModuleManager directly into a Mezzio bootstrap unless you also handle its lifecycle events yourself.
Testing a module. You do not need a full MVC application to test configuration aggregation:
$manager = new ModuleManager(['Application']);
$listener = new ConfigListener(new ListenerOptions(['config_cache_enabled' => false]));
$manager->getEventManager()->attach($listener);
$manager->loadModules();
$config = $listener->getMergedConfig();
self::assertArrayHasKey('router', $config);
This is also a good way to catch config that depends on a specific module order before you ship.
config_cache_enabled => true means Module::getConfig() is not called at all on subsequent requests. Clear data/cache (or change config_cache_key) on every deploy and every local config change.config_glob_paths order matters. The conventional {,*.}{global,local}.php pattern loads *.global.php before *.local.php, so local overrides global. If you add new glob patterns, put them in the intended precedence order.init() is too early for merged config. init() runs while modules load; config aggregation completes at the post-load phase. Use onBootstrap() (or a low-priority post-load listener) when you need the merged config array.ModuleResolverListener instantiates <ModuleName>\Module. If the namespace is not in Composer's autoloader (or module_paths/use_module_autoloaders are misconfigured), you get a class-not-found rather than a helpful "module failed to load". Verify the PSR-4 mapping first.vendor/. Create a project module (often Application) and declare the override there so load order guarantees it wins.var_dump in Module.php. Dump getLoadedModules() keys to confirm ordering, and inspect ConfigListener::getMergedConfig() to see the final array. For a stuck value, check the cache first, then list the modules and their load order.setEventManager() before loading if you want isolated listeners, and type-hint ModuleManagerInterface in module code rather than the concrete class.Module class, config files, and two lifecycle hooks. If you find yourself writing elaborate listeners, check whether Mezzio's ConfigProvider/ConfigAggregator model would be simpler — for new non-MVC projects it usually is.How can I help you explore Laravel packages today?