Introducing the Symfony Dependency Injection Container in Legacy Code
GetAway is all about big ideas and even bigger ambitions. We don’t just want to build the best products for our users; we also want to be proud of the software architecture and code we produce. However, like most companies, we also have legacy systems to maintain.
I know that many developers groan at the thought of working with legacy code, but is that really justified? After all, these systems have made companies successful over the years. Nevertheless, it’s always a good idea to refactor and modernize them to bring them closer to current standards.
The Challenge of Change
For newer developers, tackling legacy code can be intimidating. Making changes or fixing bugs often feels like trying to defuse a bomb. One wrong move, and the ripple effects could be catastrophic. This hesitation is understandable but also unsustainable in the long term. To keep this system relevant and maintainable, we knew it was time for a change.
One essential step is writing unit tests to ensure that everything works the same before and after refactoring. For unit tests, testable code is a must. One major obstacle to testability is dependencies — after all, we only want to test a unit and mock the rest. However, in legacy systems, the code is often not testable due to hard-coupled dependencies and stateful classes. To solve this issue, we decided to introduce the Symfony Dependency Injection (DI) Container.
This article will walk you through our journey and demonstrate how straightforward the process can be, so you can overcome any hesitation about improving legacy code. While there are some important considerations, the process is much easier than it sounds.
Step-by-Step Integration of the Symfony DI Container
1. Installing the Symfony Dependency Injection Container
If the legacy project does not yet use Composer, this should be introduced first. Then, the DI container can be installed:
composer require symfony/dependency-injection2. Defining Services
The next step is defining services within the service configuration file. Since we follow the Service Layer pattern, we separate concerns into models, controllers, and services. By using autowiring, we let Symfony automatically inject dependencies where needed, reducing boilerplate code.
A basic service configuration using YAML might look like this:
services:
_defaults:
autowire: true # automatically injects dependencies in your services
autoconfigure: true # automatically registers your services as commands, event subscribers, etc.
public: false # prevents fetching services directly from the container via $container->get()
# Models
projects\model\:
resource: '../../projects/model/**/*'
public: true
# Controllers
projects\www\:
resource: '../../projects/www/**/*Controller.php'
public: true
# Services
projects\Service\:
resource: '../../projects/Service/**/*Service.php'
public: true
3. Creating a DI Container
Since our legacy project has multiple entry points, we implement a factory using the Singleton pattern. This ensures that only one instance of the container is created.
Modern frameworks like Symfony and Laravel provide a Dependency Injection Container (DI container) that centrally manages service instances. This often makes manual singleton implementation unnecessary, as the container by default provides only a single instance per request for specific services (shared services).
The Singleton pattern is particularly useful when no centralized lifecycle manager exists, and multiple uncontrolled instantiations could lead to inconsistencies or unnecessary memory overhead. However, in modern DI-driven architectures, it is often more practical to leverage the service container for instance management rather than enforcing the singleton pattern manually.
3.1 Implementing the Container Factory
The ContainerFactory class is responsible for managing the dependency injection container, loading cached versions when available, and building a new container when needed.
class ContainerFactory
{
private static ?ContainerInterface $container = null;
private static string $CACHE_DIR = __DIR__ . '/cache';
private static string $CACHE_FILE = '/container_cached.php';
public static function getContainer(): ContainerInterface
{
if (self::$container === null) {
self::$container = self::loadContainer();
}
return self::$container;
}
private static function loadContainer(): ContainerInterface
{
// Load cached container if available
if (file_exists(self::$CACHE_DIR . self::$CACHE_FILE)) {
require_once self::$CACHE_DIR . self::$CACHE_FILE;
return new \CompiledServiceContainer();
}
// Otherwise, build and cache a new container
return self::buildAndCacheContainer();
}
private static function buildAndCacheContainer(): ContainerInterface
{
$container = new ContainerBuilder();
// Register services, avoiding direct storage of sensitive data
$container->register('database_config', 'array')
->setFactory([self::class, 'getDatabaseConfig']);
// Load service configurations
$configPath = realpath(__DIR__ . '/config');
if (!$configPath) {
throw new \RuntimeException('Config path not found');
}
$loader = new YamlFileLoader($container, new FileLocator($configPath));
$loader->load('services.yaml');
// Compile and cache the container
$container->compile();
self::cacheContainer($container);
return $container;
}3.2 Caching for Performance
One of the biggest concerns when introducing a DI container in a legacy system is performance overhead. Since autowiring dynamically resolves dependencies, it can cause increased memory usage and slower response times.
To mitigate this, we cache the compiled dependency injection container:
private static function cacheContainer(ContainerBuilder $container): void
{
// Ensure cache directory exists and is secured
if (!is_dir(self::$CACHE_DIR) && !mkdir(self::$CACHE_DIR, 0700, true) && !is_dir(self::$CACHE_DIR)) {
throw new \RuntimeException('Failed to create cache directory');
}
// Dump compiled container to cache file
$dumper = new PhpDumper($container);
file_put_contents(self::$CACHE_DIR . self::$CACHE_FILE, $dumper->dump());
}With this caching mechanism:
- The compiled container is stored in container_cached.php, significantly reducing load times.
- The CACHE_DIR is created with secure file permissions (
0700) to prevent unauthorized access. - If the cache does not exist, it is automatically created and used in subsequent requests.
3.3 Managing Sensitive Data
In our case, database credentials are injected into some classes via constructor injection. If caching is not handled properly, these credentials can end up in plaintext. To prevent this, we registered a factory (getDatabaseConfig) that loads credentials from environment variables only when needed.
public static function getDatabaseConfig(): array
{
// Replace placeholders with ENV variables or a secure secret manager
return [
'db' => 'DATABASE_NAME',
'dsn' => 'DATABASE_DSN',
'username' => 'DATABASE_USER',
'password' => 'DATABASE_PASSWORD'
];
}
}This ensures:
- Secrets are not stored in code and Cache and can be managed securely.
4. Using the Container in Code
Once the DI container is set up, we can use it in our application by calling:
$container = ContainerFactory::getContainer();This provides access to all registered services, ensuring that dependencies are managed automatically. For example, if we need an instance of a service:
$myService = $container->get(\App\Service\MyService::class);
$myService->doSomething();By using dependency injection:
- We eliminate the need for manually instantiating objects.
- Services are automatically resolved and injected where needed.
- The system becomes more modular and testable.
Final Thoughts
Modernizing a legacy system doesn’t mean tearing it down and starting from scratch. Instead, it’s about making thoughtful, incremental improvements. Our first step was Introducing the Symfony Dependency Injection (DI) Container into the system. This simple but powerful addition brings structure to the system and opens the door for further enhancements down the line.

