Modernising ZF1 applications: shared Service Container

by Mateusz Tymek — on PHP, Zend Framework

In the previous part, we got Zend Expressive working on top of legacy Zend Framework 1 application. This was only the first step towards deeper integration. In this post, I will show how to bring these apps closer by introducing shared Service Container. It will open up new strategies of refactoring.

Sharing services between legacy and modern code

Let's start with an example. Say you have a fat, ugly controller that has 2000 lines of code and completely depends on application's global state. You would like to reuse the logic somewhere else. Your strategy could look like this:

  1. Extract business logic from the old controller into a new class.
  2. Create a factory for this new class and modify it, allowing constructor injection of dependencies.
  3. Write tests.
  4. Refactor it into multiple classes, according to the Single Responsibility Principle. Use Service Container to wire new classes together.
  5. Use refactored service somewhere in the new code.

Strategies such as this one are difficult to pull off without proper dependency management. In the worst case, you may end up with duplicated or untested code. You will risk introducing more technical debt than you will reduce by refactoring.

The solution proposed here will allow using modern services in legacy code so that you can strip it part by part during the longer period of time.

Service Container in Zend Expressive apps

We need a way to access modern services from within the old code. Normally they are managed by "Service Container" - usually an instance of Zend\ServiceManager, AuraDi or Symfony's Dependency Injection component. Service Container is available for Zend Expressive application and can be accessed from the factories. We need a convenient way of accessing it from the old controllers and services.

Injecting Container into a legacy application

There's no way of injecting anything directly into ZF1 controllers - they are created by Zend_Controller_Dispatcher_Standard and we do not want to modify the framework code. We can do it indirectly, by setting container as a "parameter" of Zend_Controller_Front:

Zend_Controller_Front::getInstance()->setParam('serviceContainer', $container);

In modern code, this should be avoided - it is basically a Service Locator, an anti-pattern. However, when refactoring legacy code, we are can afford such compromises. It is OK to take one step back in order to go ten steps forward.

You can inject Service Container in Zf1ApplicationFactory class (prepared in the previous article), so that it will look like this:

<?php

declare(strict_types=1);

namespace App\Integration;

use Psr\Container\ContainerInterface;
use Zend_Application;
use Zend_Controller_Front;

class Zf1ApplicationFactory
{
    public function __invoke(ContainerInterface $container): Zend_Application
    {
        defined('APPLICATION_PATH')
            || define('APPLICATION_PATH', realpath('application'));
        defined('APPLICATION_ENV')
            || define('APPLICATION_ENV', (getenv('APPLICATION_ENV') ? getenv('APPLICATION_ENV') : 'production'));
        set_include_path(implode(PATH_SEPARATOR, array(
            realpath(APPLICATION_PATH . '/../vendor/zendframework/zendframework1/library'),
            get_include_path(),
        )));
        $application = new Zend_Application(
            APPLICATION_ENV,
            APPLICATION_PATH . '/configs/application.ini'
        );

          // inject Service Container
        Zend_Controller_Front::getInstance()->setParam('serviceContainer', $container);

        return $application;
    }
}

This will allow using shared services from old controllers using getInvokeArg():

/** @var \Psr\Container\ContainerInterface $serviceContainer */
$serviceContainer = $this->getInvokeArg('serviceContainer');
$helloService = $serviceContainer->get(HelloService::class);

// use shared service
$this->view->helloMessage = $helloService->hello();

In other parts of legacy code you can relay Zend_Controller_Front singleton:

/** @var \Psr\Container\ContainerInterface $serviceContainer */
$serviceContainer = \Zend_Controller_Front::getInstance()
                       ->getParam('serviceContainer');
$helloService = $serviceContainer->get(HelloService::class);

// use shared service
$this->view->helloMessage = $helloService->hello();

While relying on global state is something we would like to avoid, sometimes it can be a lesser evil - in this case, it allows a more gradual approach in refactoring the legacy code.

Conclusion

Today we made another important step towards improving our legacy application. With the Service Container available in old controllers, you have a powerful tool in your pocket.

At this moment our application is still fragile - for example, you cannot rely on runtime PHP settings if they are set by ZF1. We will deal with this inconsistiencies in the next article in the series.

You can check out the working example on Github, by cloning this repository and checking out part3-service-manager-integration branch.


comments powered by Disqus