You've inherited a Zend Framework 2 application. Maybe it's an internal admin panel that pays the bills. Maybe it's the billing service that nobody wants to rewrite because the test coverage is thin and the side effects are everywhere. Either way, the framework was officially handed off to the Linux Foundation in 2019 and lives on as Laminas, but your composer.json still reads zendframework/zend-mvc, and that's not changing this quarter.

The instinct is to either freeze the code in amber or rewrite the whole thing in something modern. Both are wrong. Frozen code rots: security patches stop, dependencies fall behind, the people who knew the quirks leave. Rewrites take twice as long as anyone planned and ship with a different set of bugs than the ones you were trying to fix.

The middle path is the one most teams never quite settle into: keep the app alive, ship features in it, and avoid the small handful of moves that cause ZF2 codebases to mysteriously break in production. This piece is about that middle path, module by module, service by service, with enough detail to actually work in the codebase rather than just admire it from the outside.

Why ZF2 codebases feel different

If your reference frame is Laravel or Symfony, ZF2 looks over-engineered. There's a Module.php per module. There's a module.config.php per module. There's a ServiceManager, a ControllerManager, a ControllerPluginManager, a ViewHelperManager, a FormElementManager, and they all behave like the main service container but each has its own configuration key and its own class.

That isn't an accident. ZF2 was designed when the PHP world was arguing about dependency injection, and its answer was: every collaborator goes through a container, and every kind of collaborator gets its own container, configured separately. It's verbose by design. Once you internalise that, the rest of the framework stops feeling weird.

The two practical consequences for legacy work are these. First, almost every "where does this come from" question has the same answer: check the relevant manager's config in module.config.php. Second, almost every silent breakage is a service manager configuration drift: a factory pointing at the wrong class, a controller registered on one manager but resolved on another, a plugin shadowed by a later module.

If you remember nothing else from this piece, remember those two things. Most ZF2 debugging is service-manager archaeology.

The module system

A ZF2 application is a list of modules in config/application.config.php:

PHP config/application.config.php
<?php

return [
    'modules' => [
        'Application',
        'Auth',
        'Billing',
        'Reporting',
        'DoctrineModule',
        'DoctrineORMModule',
    ],
    'module_listener_options' => [
        'config_glob_paths'    => ['config/autoload/{,*.}{global,local}.php'],
        'module_paths'         => ['./module', './vendor'],
        'config_cache_enabled' => true,
        'config_cache_key'     => 'application.config.cache',
    ],
];

Each module is a folder that declares itself with a Module class. The framework loads them in order, calls a small set of optional hook methods on each, then merges their configs together. That merged config is what every other manager reads.

The minimum a module declares looks like this:

PHP module/Billing/Module.php
<?php

namespace Billing;

use Zend\ModuleManager\Feature\ConfigProviderInterface;
use Zend\ModuleManager\Feature\AutoloaderProviderInterface;

class Module implements
    ConfigProviderInterface,
    AutoloaderProviderInterface
{
    public function getConfig()
    {
        return include __DIR__ . '/config/module.config.php';
    }

    public function getAutoloaderConfig()
    {
        return [
            'Zend\Loader\StandardAutoloader' => [
                'namespaces' => [
                    __NAMESPACE__ => __DIR__ . '/src/' . __NAMESPACE__,
                ],
            ],
        ];
    }
}

A few things worth noticing right away.

The getAutoloaderConfig() method only matters if you're not on Composer's PSR-4 autoloader. Most modern ZF2 codebases let Composer handle autoloading and skip the AutoloaderProviderInterface entirely. If you see getAutoloaderConfig() in a module that already has a Composer PSR-4 entry, it's redundant but harmless.

The getConfig() return is merged with every other module's getConfig() return. Last module wins on key collisions, and config in config/autoload/*.local.php wins over module config. This is the merge order you have to keep in your head whenever you debug "why is the wrong value here":

Text
1. Module A → getConfig()
2. Module B → getConfig()
3. ... (all modules in order)
4. config/autoload/*.global.php (alphabetical)
5. config/autoload/*.local.php (alphabetical)

The next-to-last point is the one that bites teams who add a "config override module" near the end of the list and then can't figure out why their local.php settings aren't taking effect. They are taking effect. The override module is being clobbered by the local file, which is exactly what should happen but exactly the opposite of what people expect when they wrote the override module.

The other hook you'll see constantly is onBootstrap(), which fires after all modules are loaded:

PHP module/Billing/Module.php: onBootstrap
public function onBootstrap(MvcEvent $e)
{
    $eventManager = $e->getApplication()->getEventManager();
    $eventManager->attach(MvcEvent::EVENT_DISPATCH_ERROR, [$this, 'handleError']);
    $eventManager->attach(MvcEvent::EVENT_RENDER_ERROR, [$this, 'handleError']);
}

This is where modules wire themselves into the request lifecycle. It's also where mysterious global behaviour comes from, because anything attached here runs for every request the application handles, not just routes belonging to this module. If you're hunting for "why does this header get added on every response", grep -r 'attach(MvcEvent::' module/ is usually a faster path than the debugger.

The Service Manager

The Service Manager is the heart of ZF2. Everything else (controllers, plugins, view helpers, form elements) is a specialised service manager underneath. Get comfortable with this one and the rest are the same with different config keys.

A module registers services through module.config.php:

PHP module/Billing/config/module.config.php
<?php

return [
    'service_manager' => [
        'invokables' => [
            'Billing\Service\TaxCalculator' => 'Billing\Service\TaxCalculator',
        ],
        'factories' => [
            'Billing\Service\InvoiceGenerator' => 'Billing\Service\Factory\InvoiceGeneratorFactory',
            'Billing\Repository\InvoiceRepository' => 'Billing\Repository\Factory\InvoiceRepositoryFactory',
        ],
        'abstract_factories' => [
            'Billing\Service\AbstractFactory\PaymentGatewayAbstractFactory',
        ],
        'aliases' => [
            'TaxCalculator' => 'Billing\Service\TaxCalculator',
        ],
        'shared' => [
            'Billing\Service\InvoiceGenerator' => false,
        ],
    ],
];

That's the full vocabulary. Five keys; each one means something specific.

invokables are services with no constructor dependencies. The Service Manager just calls new ClassName(). If you've added a constructor argument to an invokable service and started getting "missing argument" errors at runtime, congratulations: you've discovered why factories exist.

factories are the workhorse. Each entry maps a service name to a factory class that knows how to construct it. ZF2 went through two factory styles:

PHP Billing/Service/Factory/InvoiceGeneratorFactory.php: old style (servicemanager 2.x)
<?php

namespace Billing\Service\Factory;

use Zend\ServiceManager\FactoryInterface;
use Zend\ServiceManager\ServiceLocatorInterface;

class InvoiceGeneratorFactory implements FactoryInterface
{
    public function createService(ServiceLocatorInterface $serviceLocator)
    {
        $repo = $serviceLocator->get('Billing\Repository\InvoiceRepository');
        $tax  = $serviceLocator->get('Billing\Service\TaxCalculator');

        return new \Billing\Service\InvoiceGenerator($repo, $tax);
    }
}
PHP Billing/Service/Factory/InvoiceGeneratorFactory.php: new style (servicemanager 3.x+, recommended)
<?php

namespace Billing\Service\Factory;

use Interop\Container\ContainerInterface;
use Zend\ServiceManager\Factory\FactoryInterface;

class InvoiceGeneratorFactory implements FactoryInterface
{
    public function __invoke(ContainerInterface $container, $requestedName, array $options = null)
    {
        $repo = $container->get('Billing\Repository\InvoiceRepository');
        $tax  = $container->get('Billing\Service\TaxCalculator');

        return new \Billing\Service\InvoiceGenerator($repo, $tax);
    }
}

In a long-lived legacy codebase you'll see both. The framework supports them in parallel. FactoryInterface from Zend\ServiceManager is the old one with createService(), while FactoryInterface from Zend\ServiceManager\Factory is the new one with __invoke(). Same name, different namespaces. When in doubt, look at the use statement at the top of the file.

You don't have to migrate all factories to the new style at once. Pick the new style for any factory you're touching anyway. Don't bulk-rewrite: that's a high-risk, low-reward change.

abstract_factories are factories that can produce many services. They get asked, in order, can you build this? until one says yes. They're powerful and they're also where ZF2 service resolution becomes genuinely hard to debug, because the answer to "where does service X come from" is no longer in the config files at all. It's in the canCreateServiceWithName() (old) or canCreate() (new) method of some abstract factory.

aliases map one name to another. Useful for short names ('Logger' => 'My\Module\Service\Logger') and for re-pointing a default at your own implementation without rewriting all the call sites.

shared controls whether services are singletons (the default, true) or constructed fresh each time (false). This is where the most subtle bugs in legacy ZF2 apps come from. A service that holds state (a unit of work, a request-scoped cache, a session-bound user object) needs 'shared' => false or it'll leak state between requests in long-running workers.

Horizontal flow diagram showing how a service resolves in ZF2: starting from $container-&gt;get(...), the container checks shared cache, invokables, factories, abstract_factories, and aliases in order, ending in the returned InvoiceGenerator instance.

A practical pattern for staying sane in a ZF2 codebase: when you can't figure out what a service actually is, run this in a controller action or a console script:

PHP quick service introspection
$serviceName = 'Billing\Service\InvoiceGenerator';
$instance    = $container->get($serviceName);
echo get_class($instance), "\n";
echo $container->has($serviceName) ? 'configured' : 'not configured', "\n";

get_class() tells you what was actually built, which is sometimes not the class you'd guess from the service name, because aliases and abstract factories love to hide the truth. has() confirms whether the framework even knows about the service before you waste twenty minutes assuming it does.

Controllers

Controllers in ZF2 are not just classes you write. They're services registered with a separate container called the ControllerManager, with its own config key:

PHP module/Billing/config/module.config.php: controllers section
<?php

return [
    'controllers' => [
        'invokables' => [
            'Billing\Controller\Health' => 'Billing\Controller\HealthController',
        ],
        'factories' => [
            'Billing\Controller\Invoice' => 'Billing\Controller\Factory\InvoiceControllerFactory',
        ],
    ],
];

The keys here are controller service names, not class names. The router refers to controllers by service name, the dispatch pulls them from the ControllerManager by service name, and the service name is what shows up in error logs.

This separation matters because the ControllerManager is a separate container from the main ServiceManager. A controller registered as a factory in the service_manager key but referenced from the router doesn't work, and the error you get is a confusing "controller of name X not found" rather than "controller is in the wrong container". When you see that error, the answer is almost always: I registered this on the wrong service manager.

A controller factory looks like a service factory and reaches into the parent service manager for collaborators:

PHP Billing/Controller/Factory/InvoiceControllerFactory.php
<?php

namespace Billing\Controller\Factory;

use Interop\Container\ContainerInterface;
use Zend\ServiceManager\Factory\FactoryInterface;

class InvoiceControllerFactory implements FactoryInterface
{
    public function __invoke(ContainerInterface $container, $requestedName, array $options = null)
    {
        $parent  = $container->getServiceLocator() ?: $container;
        $service = $parent->get('Billing\Service\InvoiceGenerator');

        return new \Billing\Controller\InvoiceController($service);
    }
}

Two notes on that. The $container->getServiceLocator() call is the old-style way of escaping from the ControllerManager back up to the main ServiceManager. In newer ZF2 versions and Laminas, the controller manager is the parent container's child, and $container->get(...) resolves through the parent automatically, so the getServiceLocator() dance is unnecessary. In older codebases it's still everywhere.

The controller class itself extends AbstractActionController:

PHP Billing/Controller/InvoiceController.php
<?php

namespace Billing\Controller;

use Billing\Service\InvoiceGenerator;
use Zend\Mvc\Controller\AbstractActionController;
use Zend\View\Model\ViewModel;
use Zend\View\Model\JsonModel;

class InvoiceController extends AbstractActionController
{
    private $invoices;

    public function __construct(InvoiceGenerator $invoices)
    {
        $this->invoices = $invoices;
    }

    public function viewAction()
    {
        $id      = (int) $this->params()->fromRoute('id');
        $invoice = $this->invoices->load($id);

        return new ViewModel(['invoice' => $invoice]);
    }

    public function exportAction()
    {
        $id      = (int) $this->params()->fromRoute('id');
        $invoice = $this->invoices->load($id);

        return new JsonModel($invoice->toArray());
    }
}

A few things that are easy to forget when you've been away from ZF2 for a while.

Action methods end in Action. The dispatcher takes the matched route's action parameter, lower-cases it, strips dashes, and appends Action. So a route action of view-invoice calls viewInvoiceAction(). Forgetting the Action suffix is a five-minute debugging session that ends in a 404 Action not found error, and the answer is always the same.

$this->params() is a controller plugin. It's not a property; it's a magic method that resolves through the ControllerPluginManager. Same for $this->url(), $this->redirect(), $this->getRequest(), $this->layout(). Each one is a separate plugin registered on its own little container. If you write a custom plugin and it can't be found, the answer is register it in the controller_plugins section of module.config.php, not the service_manager section.

Returning a ViewModel is conventional, not required. You can return a string, an array (which becomes a ViewModel automatically), a JsonModel, or a Response object. The renderer picks the right path based on type. This flexibility is good when you understand it and a source of "why does my JSON come back as HTML" bugs when you don't.

Routing

Routing in ZF2 is config. There's no fluent route builder hidden behind a facade. Routes live in module.config.php under 'router' => ['routes' => [...]]:

PHP module/Billing/config/module.config.php: router section
<?php

return [
    'router' => [
        'routes' => [
            'invoice' => [
                'type'    => 'segment',
                'options' => [
                    'route'    => '/invoice/:id[/:action]',
                    'defaults' => [
                        'controller' => 'Billing\Controller\Invoice',
                        'action'     => 'view',
                    ],
                    'constraints' => [
                        'id'     => '[0-9]+',
                        'action' => '[a-z][a-z-]*',
                    ],
                ],
                'may_terminate' => true,
                'child_routes'  => [
                    'export' => [
                        'type'    => 'literal',
                        'options' => [
                            'route'    => '/export.pdf',
                            'defaults' => ['action' => 'export'],
                        ],
                    ],
                ],
            ],
        ],
    ],
];

The key things to keep in your head when working with these:

Route types are pluggable. Out of the box you get literal, segment, regex, hostname, scheme, method, and query. Each one has its own options shape, which is documented in the framework docs but rarely visible from the call site. When you copy a route and change the type, copy the options shape too. segment uses :placeholder syntax, regex uses PCRE, and they don't accept each other's keys.

Constraints are not optional. The id constraint above ('[0-9]+') isn't decoration; without it, the route matches /invoice/blue/anything and your action gets handed blue as the integer ID. Constraints are how you enforce route shape, and skipping them is how you end up with type errors deep in a service that thought it was being called with a number.

Child routes inherit from their parent. The child_routes array under 'invoice' means routes like invoice/export start matching after the parent route prefix is matched. Defaults flow down. This is great for keeping route trees DRY and confusing for anyone reading the routes for the first time, because the controller for invoice/export is not visible in the child route. It's inherited.

The route name in the array is what $this->url('invoice', [...]) resolves. Child route names are dotted: $this->url('invoice/export', ['id' => 42]) builds the export URL. Get one slash wrong and the view helper silently produces a wrong URL, which then 404s only when someone clicks it.

A small habit that pays off in legacy ZF2 work: keep a console command or a debug action somewhere that dumps the full merged router config:

PHP quick router dump
$config = $container->get('config');
print_r($config['router']['routes']);

When you can't figure out why a request goes to the wrong controller, or any controller at all, the answer is in that merged array, and looking at it directly is faster than guessing which module clobbered which.

The MvcEvent lifecycle

The MVC layer in ZF2 is event-driven. A request goes through a sequence of named events, and any module can attach listeners to any event. The events, in dispatch order:

Text
1. MvcEvent::EVENT_BOOTSTRAP        (application boots; modules' onBootstrap fires)
2. MvcEvent::EVENT_ROUTE            (router matches the request)
3. MvcEvent::EVENT_DISPATCH         (controller is resolved and executed)
4. MvcEvent::EVENT_DISPATCH_ERROR   (only on failure during dispatch)
5. MvcEvent::EVENT_RENDER           (view renderer turns model into output)
6. MvcEvent::EVENT_RENDER_ERROR     (only on failure during render)
7. MvcEvent::EVENT_FINISH           (response is finalised and sent)

Most of the time you don't think about this. You write a controller, it returns a ViewModel, the framework renders it, and the response goes out. The lifecycle becomes important the moment you need to do something across the application: auth, request logging, cross-cutting transforms.

A typical example is forcing a JSON response on certain routes:

PHP Module.php: JSON-only API listener
public function onBootstrap(MvcEvent $e)
{
    $eventManager = $e->getApplication()->getEventManager();
    $sharedEvents = $eventManager->getSharedManager();

    $sharedEvents->attach(
        'Zend\Mvc\Controller\AbstractActionController',
        MvcEvent::EVENT_DISPATCH,
        function (MvcEvent $e) {
            $controller = $e->getTarget();
            if (strpos(get_class($controller), 'Billing\Controller\Api\\') !== 0) {
                return;
            }

            $result = $e->getResult();
            if (is_array($result)) {
                $e->setResult(new \Zend\View\Model\JsonModel($result));
            }
        },
        -100 // run after the controller's own dispatch returns
    );
}

That listener attaches to EVENT_DISPATCH on every action controller, but only acts when the target controller is in the Billing\Controller\Api namespace. The negative priority means it runs after the controller's own action method has returned, so it can rewrite the result. (strpos(...) !== 0 works on every supported PHP version; on PHP 8+ you can use str_starts_with(...) instead.)

The two things that catch people out with the event manager:

Shared events vs application events. $eventManager->attach(...) listens for events on the application's event manager. $sharedEvents->attach('TargetClass', ...) listens for events emitted by instances of TargetClass on their own event manager. The MVC dispatch event is a shared event keyed by the controller class, which is why JSON-style listeners always go through $sharedEvents and almost never through the plain $eventManager.

Priority controls order, not whether something runs. Listeners with higher priority run first. Returning a value from a listener doesn't stop the chain; only $e->stopPropagation(true) does. Forgetting that, and assuming "I returned a Response, surely the rest of the listeners won't run" is a classic ZF2 bug that produces double-set headers and weird body content.

When something cross-cutting goes wrong (a header that shouldn't be there, an extra database query on every request, a redirect that fires under conditions you don't expect), it's almost always an MvcEvent listener somewhere, and a grep -rn 'attach(MvcEvent::' module/ vendor/ is how you find it.

Working with view models, layouts, and view helpers

Views in ZF2 follow the same separated-container pattern as everything else. The template_path_stack and template_map go in view_manager:

PHP module/Billing/config/module.config.php: view_manager section
<?php

return [
    'view_manager' => [
        'template_path_stack' => [
            __DIR__ . '/../view',
        ],
        'template_map' => [
            'billing/invoice/view'   => __DIR__ . '/../view/billing/invoice/view.phtml',
            'billing/invoice/export' => __DIR__ . '/../view/billing/invoice/export.phtml',
        ],
    ],
    'view_helpers' => [
        'invokables' => [
            'formatMoney' => 'Billing\View\Helper\FormatMoney',
        ],
    ],
];

template_map is the explicit, fast path: direct filename. template_path_stack is the convention-based fallback, where the view resolver will look for billing/invoice/view.phtml under any path in the stack. Both work; map is faster and more explicit, stack is more flexible.

The default view template for a controller action is derived from the controller and action names. Billing\Controller\InvoiceController::viewAction becomes billing/invoice/view. When you want to render a different template explicitly:

PHP setting a non-default template
public function viewAction()
{
    $view = new ViewModel(['invoice' => $this->invoices->load(1)]);
    $view->setTemplate('billing/invoice/custom-view');
    return $view;
}

To turn off the layout for a single action, common for AJAX endpoints that should return a partial:

PHP disabling the layout
public function fragmentAction()
{
    $view = new ViewModel(['data' => $this->load()]);
    $view->setTerminal(true);
    return $view;
}

setTerminal(true) tells the renderer "don't wrap me in a layout, this is the response." Forgetting it is the reason your AJAX endpoint returns an HTML page with a navbar, footer, and your fragment buried in the middle.

View helpers register on the ViewHelperManager, which is, you guessed it, yet another container with its own config key. The pattern's the same: invokables for no-dep helpers, factories for ones that need collaborators.

Adding new features safely

Now the practical part: you have a working ZF2 app and you have to add something to it. The way you do this without destabilising the rest of the system is to add a module, not to edit existing ones.

Modules are the unit of isolation in ZF2. A new feature that lives in its own module gets its own Module.php, its own module.config.php, its own service factories, its own controllers, its own templates. It can depend on services from other modules, but other modules don't have to know it exists. When something breaks, the blast radius is bounded.

A safe-feature playbook that survives in legacy ZF2 work:

Make the new module depend inward, not outward. Pull services from Billing or Auth. Don't have Billing reach back into your new module. The dependency direction has to point at the older, more stable code. If you violate this, your new module becomes part of the load-bearing core whether you wanted it to or not.

Resolve everything through the container. Don't new anything that has a non-trivial constructor. The reason isn't dogma. It's that in three months when you need to swap an implementation or write a test, container-resolved dependencies can be replaced by changing one factory. new'd ones can't.

Interface your boundaries. When the new module talks to Billing, talk to a BillingGateway interface that lives in your module, with an implementation that wraps the real Billing services. This costs you one class today and saves you from a "we changed the Billing service signature and now everything breaks" rewrite later.

Don't touch shared events lightly. Any listener you attach in onBootstrap() runs for the whole application. If you add an auth listener that returns a 401 under "edge case" conditions, you'll discover at 3am that the edge case is more common than you thought. Listeners that affect every request need their own tests, just like controllers do.

Keep the new module's module.config.php small and readable. It's the single source of truth for what this module ships. Future-you needs to scan it in one screen and know what's registered. Splitting it across includes is a tempting cleanup that makes legacy archaeology much harder.

Testing what you can, around what you can't

ZF2 ships test helpers in Zend\Test\PHPUnit\Controller\AbstractHttpControllerTestCase, and they work, if your application can be booted from application.config.php cleanly without external dependencies. In a real legacy app it usually can't, because the bootstrap pulls in Doctrine connections to a real database, calls out to a payment gateway, and reads from Redis on the way up.

The pragmatic split for legacy ZF2 testing:

Unit-test services in isolation. A service like InvoiceGenerator that takes its collaborators in the constructor is a plain PHP class. Mock the collaborators, test the logic, ignore the framework. This is where most of the meaningful coverage gets built.

Integration-test the controller through the container, not through HTTP. Build a thin test base that loads application.config.php, swaps in a test database connection, and resolves the controller from the ControllerManager. Then call the action method directly. You skip the routing and the response-rendering layers but you get the wiring tested.

Save full HTTP tests for the smoke-test layer. A handful of AbstractHttpControllerTestCase tests that hit critical endpoints end-to-end is enough to catch routing config drift. Don't try to write a full HTTP test for every action. The maintenance cost in a legacy app is rarely worth it.

The mistake most teams make is the inverse: they try to write HTTP-level tests for everything, hit framework-bootstrap problems, give up, and conclude "ZF2 is untestable." It isn't. It's just that the easy wins are at the unit and container-integration layers, not the HTTP one.

The migration question (and when to actually answer it)

Eventually someone asks: should we move off ZF2? The honest answer is probably yes, but not yet.

ZF2 itself is unmaintained. The project moved to Laminas in 2019 under the Linux Foundation, and the Zend-prefixed packages on Packagist are frozen at their final 2019 versions. There's a one-to-one rename from Zend\X to Laminas\X, and Laminas ships a migration tool (laminas/laminas-migration) that does most of the rewrite mechanically. If you're going to move at all, the first step is the namespace bump to Laminas. The framework underneath is identical.

After Laminas, the question is whether to keep going to a different framework (Symfony or Laravel are the usual candidates) or to stay on Laminas indefinitely. Laminas is actively maintained, gets security patches, and supports modern PHP versions. It's a perfectly reasonable place to stay if your team knows the patterns and the application doesn't have new pressure to adopt features that Laminas doesn't ship.

The migration is not worth doing if any of the following are true:

The app is in maintenance mode and gets a few small changes per quarter. Migrating costs more than the bug-fix budget for years.

The team has no working knowledge of the destination framework. You'll be debugging two unfamiliar systems at once, and the destination's patterns will look just as weird to you as ZF2 does to a Laravel team member.

You don't have integration tests. Without them, you have no way to know whether the migration preserved behaviour. The first thing to do is build the test layer, then think about migration. That order isn't optional.

The migration is worth doing when ZF2 starts to actively cost you: packages you want to use don't support it, security patches stop landing in time, hiring is hard because nobody's worked with it in five years, or the team is rewriting features around the framework's limits. At that point the cost of staying eclipses the cost of moving, and the rename to Laminas is the cheap first step to get back onto a maintained line.

What to internalise

If you're going to spend time in a ZF2 codebase, the few things that actually pay back the time you spend learning them:

The Service Manager and its specialised siblings (ControllerManager, ControllerPluginManager, ViewHelperManager, FormElementManager) all behave the same way and live under different config keys. Most "where did this come from" questions are answered by reading the right key.

The merge order of module configs (modules in declaration order, then global.php, then local.php, alphabetical within each) is the source of every "wrong value at runtime" mystery.

onBootstrap() and MvcEvent listeners are global. They run for every request the application handles. Treat them like load-bearing infrastructure, not glue code.

New features go in new modules with inward-pointing dependencies and an interface boundary. This is what makes ZF2 codebases survivable instead of brittle.

Test what's testable cheaply (unit-level services, container-resolved controllers) and save full HTTP tests for a small smoke layer. Don't aim for full HTTP coverage in a legacy app. It's a trap.

Migration to Laminas is the cheap first step when the time comes; everything beyond that is a real project and needs the same scoping any framework move would. Don't conflate the rename with the rewrite.

ZF2 isn't a bad framework. It's a framework from a different era of PHP, designed around assumptions that haven't aged the same way Laravel's or Symfony's have. The codebase you've inherited works. Treat it like a working codebase: improve the parts you have to touch, leave the rest alone, and add new features as their own modules. You'll get more done than the team that decided to rewrite everything.